import { describe, it, expect, beforeEach } from 'vitest'; import { setupQuoteCollapse, collapsePlainTextQuotes, QUOTE_TOGGLE_ATTR, QUOTE_COLLAPSED_ATTR } from '@/lib/quote-collapse'; import { plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from '@/lib/email-sanitization'; const labels = { show: 'Show quoted text', hide: 'Hide quoted text' }; const toggle = () => document.body.querySelector(`[${QUOTE_TOGGLE_ATTR}]`); describe('setupQuoteCollapse', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('collapses a Gmail-style trailing quote behind a toggle', () => { document.body.innerHTML = `
Thanks, sounds good!
On Mon, Jul 20, 2026 John wrote:
Original message body
`; expect(setupQuoteCollapse(document, labels)).toBe(true); const quote = document.querySelector('.gmail_quote')!; expect(quote.style.display).toBe('none'); const btn = toggle()!; expect(btn).toBeTruthy(); expect(btn.getAttribute('aria-expanded')).toBe('false'); expect(btn.title).toBe(labels.show); // Button sits directly before the quote it controls. expect(btn.nextElementSibling).toBe(quote); }); it('toggle expands and re-collapses, restoring the original inline display', () => { document.body.innerHTML = `

reply text

quoted
`; setupQuoteCollapse(document, labels); const quote = document.querySelector('.gmail_quote')!; const btn = toggle()!; btn.click(); expect(quote.style.display).toBe('flex'); expect(quote.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false); expect(btn.getAttribute('aria-expanded')).toBe('true'); expect(btn.title).toBe(labels.hide); btn.click(); expect(quote.style.display).toBe('none'); expect(btn.getAttribute('aria-expanded')).toBe('false'); expect(btn.title).toBe(labels.show); }); it('collapses an Apple Mail cite blockquote but keeps the attribution line visible', () => { document.body.innerHTML = `
See you then.
On 20 Jul 2026, at 10:00, Jane wrote:
the original
`; expect(setupQuoteCollapse(document, labels)).toBe(true); const quote = document.querySelector('blockquote')!; expect(quote.style.display).toBe('none'); expect(document.body.textContent).toContain('Jane wrote:'); }); it('collapses Bulwark\'s own data-quoted-html reply island', () => { document.body.innerHTML = `

my answer

On Jul 20, John <j@example.com> wrote:
original
`; expect(setupQuoteCollapse(document, labels)).toBe(true); expect(document.querySelector('[data-quoted-html]')!.style.display).toBe('none'); }); it('collapses everything after an Outlook divRplyFwdMsg separator', () => { document.body.innerHTML = `
Top-posted answer
From: John
Sent: Monday
quoted paragraph one
quoted paragraph two
`; expect(setupQuoteCollapse(document, labels)).toBe(true); const divs = [...document.querySelectorAll('body > div')]; expect(divs[0].style.display).toBe(''); expect(divs[1].style.display).toBe('none'); expect(divs[2].style.display).toBe('none'); expect(divs[3].style.display).toBe('none'); }); it('collapses from a Thunderbird moz-cite-prefix attribution onwards', () => { document.body.innerHTML = `

reply

On 20.07.2026, Jane wrote:
original
`; expect(setupQuoteCollapse(document, labels)).toBe(true); expect(document.querySelector('.moz-cite-prefix')!.style.display).toBe('none'); expect(document.querySelector('blockquote')!.style.display).toBe('none'); }); it('does not collapse when the quote is the entire message', () => { document.body.innerHTML = `
forwarded content only
`; expect(setupQuoteCollapse(document, labels)).toBe(false); expect(toggle()).toBeNull(); expect(document.querySelector('.gmail_quote')!.style.display).toBe(''); }); it('does not collapse an interleaved reply (content after the quote container)', () => { document.body.innerHTML = `

first point

their question

my answer below the quote

`; expect(setupQuoteCollapse(document, labels)).toBe(false); expect(toggle()).toBeNull(); }); it('ignores whitespace-only trailing content after the quote container', () => { document.body.innerHTML = `

reply

quoted

${String.fromCharCode(160)}`; expect(setupQuoteCollapse(document, labels)).toBe(true); }); it('is idempotent per document', () => { document.body.innerHTML = `

reply

quoted
`; expect(setupQuoteCollapse(document, labels)).toBe(true); expect(setupQuoteCollapse(document, labels)).toBe(false); expect(document.querySelectorAll(`[${QUOTE_TOGGLE_ATTR}]`)).toHaveLength(1); }); it('does nothing when no quote marker exists', () => { document.body.innerHTML = `

plain email with content

and more content

`; expect(setupQuoteCollapse(document, labels)).toBe(false); expect(toggle()).toBeNull(); }); it('collapses a bare blockquote preceded by an attribution line ending in a colon', () => { // Shape of a Bulwark reply to a plain-text original (issue #480 follow-up): // no type/class on the blockquote, attribution in the preceding

. document.body.innerHTML = `

Hi Tracey,
thanks for reaching out.

On Mo., 18. Mai 2026, 20:27, Tracey Tiefisher wrote:

Hi Linus,

older nested quote

`; expect(setupQuoteCollapse(document, labels)).toBe(true); const outer = document.querySelector('blockquote')!; expect(outer.style.display).toBe('none'); expect(document.body.textContent).toContain('Tracey Tiefisher wrote:'); }); it('leaves a bare blockquote alone when the preceding text is not an attribution', () => { document.body.innerHTML = `

Our newsletter quote of the day

Stay hungry, stay foolish.
`; expect(setupQuoteCollapse(document, labels)).toBe(false); expect(toggle()).toBeNull(); }); it('collapses a nested wrapper case: separator inside a wrapper div hides content outside it too', () => { document.body.innerHTML = `
answer
quoted intro
quoted rest
`; expect(setupQuoteCollapse(document, labels)).toBe(true); const last = document.body.lastElementChild as HTMLElement; expect(last.textContent).toBe('quoted rest'); expect(last.style.display).toBe('none'); }); }); describe('sanitizer keeps the quote marker attribute', () => { it('preserves data-quoted-html through sanitizeEmailHtml', () => { const out = sanitizeEmailHtml('

hi

orig
'); expect(out).toContain('data-quoted-html'); }); }); describe('collapsePlainTextQuotes', () => { const raw = [ 'Hallo Linus,', '', 'ich sehe eben, dass du das gefixt hast, thx!', '', 'On Dienstag, 31. Maerz 2026 13:56 Richard Weinberger wrote:', '> On Dienstag, 31. Maerz 2026 00:28 Richard Weinberger wrote:', '> > Hi!', '> ', '> Here are some more issues:', '> ', '> - getClientIP() vulnerable to IP spoofing.', '', '', '-- ', 'sigma star gmbh | Eduard-Bodem-Gasse 6, 6020 Innsbruck, AUT', ].join('\n'); it('wraps the trailing quote run in
, keeping reply, attribution and signature visible', () => { const out = collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels); expect(out).toContain('
'); expect(out).toContain('•••'); expect(out).toContain(`title="${labels.show}"`); // Quote run is inside the details, attribution and signature outside. const details = out.slice(out.indexOf('
'), out.indexOf('
')); expect(details).toContain('getClientIP()'); // The nested quoted attribution (00:28) collapses, the outer one (13:56) stays. expect(details).toContain('00:28'); expect(details).not.toContain('13:56'); const outside = out.replace(details, ''); expect(outside).toContain('Hallo Linus,'); expect(outside).toContain('13:56 Richard Weinberger wrote:'); expect(outside).toContain('sigma star gmbh'); }); it('round-trips through sanitizePlainTextRenderedHtml', () => { const out = sanitizePlainTextRenderedHtml(collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels)); expect(out).toContain('
'); expect(out).toContain(' { const text = 'Tom wrote:\n> what do you think?\n\nSounds good to me!'; const safe = plainTextToSafeHtml(text); expect(collapsePlainTextQuotes(safe, labels)).toBe(safe); }); it('does not collapse when the quote is the whole message', () => { const text = '> forwarded line one\n> forwarded line two'; const safe = plainTextToSafeHtml(text); expect(collapsePlainTextQuotes(safe, labels)).toBe(safe); }); it('leaves mail without quote lines untouched', () => { const safe = plainTextToSafeHtml('just a normal message\nwith two lines'); expect(collapsePlainTextQuotes(safe, labels)).toBe(safe); }); it('never treats a ">" mid-line as a quote', () => { const safe = plainTextToSafeHtml('a -> b\nx > y comparison'); expect(collapsePlainTextQuotes(safe, labels)).toBe(safe); }); });