Files
SRCmail/lib/__tests__/quote-collapse.test.ts
T

249 lines
10 KiB
TypeScript

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<HTMLButtonElement>(`[${QUOTE_TOGGLE_ATTR}]`);
describe('setupQuoteCollapse', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('collapses a Gmail-style trailing quote behind a toggle', () => {
document.body.innerHTML = `
<div dir="ltr">Thanks, sounds good!</div>
<div class="gmail_quote">
<div class="gmail_attr">On Mon, Jul 20, 2026 John wrote:</div>
<blockquote>Original message body</blockquote>
</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const quote = document.querySelector<HTMLElement>('.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 = `
<p>reply text</p>
<div class="gmail_quote" style="display:flex">quoted</div>`;
setupQuoteCollapse(document, labels);
const quote = document.querySelector<HTMLElement>('.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 = `
<div>See you then.</div>
<div>On 20 Jul 2026, at 10:00, Jane wrote:</div>
<blockquote type="cite">the original</blockquote>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const quote = document.querySelector<HTMLElement>('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 = `
<p>my answer</p>
<div>On Jul 20, John &lt;j@example.com&gt; wrote:</div>
<div data-quoted-html style="border-left:2px solid #c5c5c5;padding-left:12px;">original</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
expect(document.querySelector<HTMLElement>('[data-quoted-html]')!.style.display).toBe('none');
});
it('collapses everything after an Outlook divRplyFwdMsg separator', () => {
document.body.innerHTML = `
<div>Top-posted answer</div>
<div id="divRplyFwdMsg"><b>From:</b> John<br><b>Sent:</b> Monday</div>
<div>quoted paragraph one</div>
<div>quoted paragraph two</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const divs = [...document.querySelectorAll<HTMLElement>('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 = `
<p>reply</p>
<div class="moz-cite-prefix">On 20.07.2026, Jane wrote:</div>
<blockquote type="cite">original</blockquote>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
expect(document.querySelector<HTMLElement>('.moz-cite-prefix')!.style.display).toBe('none');
expect(document.querySelector<HTMLElement>('blockquote')!.style.display).toBe('none');
});
it('does not collapse when the quote is the entire message', () => {
document.body.innerHTML = `
<div class="gmail_quote">forwarded content only</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(toggle()).toBeNull();
expect(document.querySelector<HTMLElement>('.gmail_quote')!.style.display).toBe('');
});
it('does not collapse an interleaved reply (content after the quote container)', () => {
document.body.innerHTML = `
<p>first point</p>
<blockquote type="cite">their question</blockquote>
<p>my answer below the quote</p>`;
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(toggle()).toBeNull();
});
it('ignores whitespace-only trailing content after the quote container', () => {
document.body.innerHTML = `
<p>reply</p>
<div class="gmail_quote">quoted</div>
<div><br></div>
${String.fromCharCode(160)}`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
});
it('is idempotent per document', () => {
document.body.innerHTML = `
<p>reply</p>
<div class="gmail_quote">quoted</div>`;
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 = `<p>plain email with content</p><p>and more content</p>`;
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 <p>.
document.body.innerHTML = `
<div>
<p>Hi Tracey,<br>thanks for reaching out.</p>
<p>On Mo., 18. Mai 2026, 20:27, Tracey Tiefisher wrote:<br></p>
<blockquote><p>Hi Linus,</p><blockquote><p>older nested quote</p></blockquote></blockquote>
<p></p>
</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const outer = document.querySelector<HTMLElement>('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 = `
<p>Our newsletter quote of the day</p>
<blockquote>Stay hungry, stay foolish.</blockquote>`;
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 = `
<div>answer</div>
<div><div id="appendonsend"></div><div>quoted intro</div></div>
<div>quoted rest</div>`;
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('<p>hi</p><div data-quoted-html style="padding-left:12px">orig</div>');
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 <details>, keeping reply, attribution and signature visible', () => {
const out = collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels);
expect(out).toContain('<details>');
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('<details>'), out.indexOf('</details>'));
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('<details>');
expect(out).toContain('<summary');
expect(out).toContain(`title="${labels.show}"`);
});
it('does not collapse a bottom-posted reply (content after the quote run)', () => {
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);
});
});