mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
applyEmailDarkMode,
|
|
contrastRatio,
|
|
darkenBackground,
|
|
DARK_PENDING_CLASS,
|
|
emailColorScheme,
|
|
ensureContrast,
|
|
parseComputedColor,
|
|
resolveColorSchemeMedia,
|
|
type Rgba,
|
|
} from '../email-dark-mode';
|
|
import { sanitizeEmailBodyForIframe } from '../email-sanitization';
|
|
|
|
const c = (value: string): Rgba => parseComputedColor(value)!;
|
|
const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3;
|
|
|
|
describe('darkenBackground', () => {
|
|
it('turns white into a dark canvas', () => {
|
|
expect(lightness(darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40);
|
|
});
|
|
|
|
it('keeps a light grey canvas lighter than a white card on it', () => {
|
|
const card = darkenBackground(c('rgb(255, 255, 255)'));
|
|
const canvas = darkenBackground(c('rgb(244, 244, 244)'));
|
|
expect(lightness(canvas)).toBeGreaterThan(lightness(card));
|
|
});
|
|
|
|
it('keeps the hue of a light tint', () => {
|
|
const out = darkenBackground(c('rgb(214, 233, 248)'));
|
|
expect(lightness(out)).toBeLessThan(80);
|
|
expect(out.b).toBeGreaterThan(out.r);
|
|
});
|
|
|
|
it('leaves vivid brand colours and dark fills as sent', () => {
|
|
const red = c('rgb(227, 6, 19)');
|
|
const green = c('rgb(115, 215, 0)');
|
|
const navy = c('rgb(0, 32, 91)');
|
|
expect(darkenBackground(red)).toBe(red);
|
|
expect(darkenBackground(green)).toBe(green);
|
|
expect(darkenBackground(navy)).toBe(navy);
|
|
});
|
|
|
|
it('keeps alpha', () => {
|
|
expect(darkenBackground(c('rgba(255, 255, 255, 0.5)')).a).toBe(0.5);
|
|
});
|
|
});
|
|
|
|
describe('ensureContrast', () => {
|
|
const dark = c('rgb(20, 20, 20)');
|
|
|
|
it('keeps text that already reads', () => {
|
|
const white = c('rgb(255, 255, 255)');
|
|
expect(ensureContrast(white, dark, 4.5)).toBe(white);
|
|
});
|
|
|
|
it('lifts dark text above 4.5:1 on a dark background', () => {
|
|
const out = ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5);
|
|
expect(contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
|
|
it('keeps the order of text greys', () => {
|
|
const black = ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5);
|
|
const grey = ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5);
|
|
expect(lightness(black)).toBeGreaterThan(lightness(grey));
|
|
});
|
|
|
|
it('keeps a red link red', () => {
|
|
const out = ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5);
|
|
expect(out.r).toBeGreaterThan(out.g + 60);
|
|
});
|
|
});
|
|
|
|
describe('resolveColorSchemeMedia', () => {
|
|
const html = '<style>@media (prefers-color-scheme: dark) { .a { color: white } } @media (prefers-color-scheme:light) { .b {} }</style><p>(prefers-color-scheme: dark)</p>';
|
|
|
|
it('turns the dark query on and the light one off in dark mode', () => {
|
|
const out = resolveColorSchemeMedia(html, true);
|
|
expect(out).toContain('@media (min-width: 0px) { .a');
|
|
expect(out).toContain('@media (max-width: 0.001px) { .b');
|
|
});
|
|
|
|
it('turns the dark query off in light mode', () => {
|
|
const out = resolveColorSchemeMedia(html, false);
|
|
expect(out).toContain('@media (max-width: 0.001px) { .a');
|
|
expect(out).toContain('@media (min-width: 0px) { .b');
|
|
});
|
|
|
|
it('leaves text outside <style> alone', () => {
|
|
expect(resolveColorSchemeMedia(html, true)).toContain('<p>(prefers-color-scheme: dark)</p>');
|
|
});
|
|
});
|
|
|
|
describe('emailColorScheme', () => {
|
|
it('is dark only for a mail with its own dark styles shown dark', () => {
|
|
expect(emailColorScheme(true, true)).toBe('dark');
|
|
expect(emailColorScheme(true, false)).toBe('light');
|
|
expect(emailColorScheme(false, true)).toBe('light');
|
|
});
|
|
});
|
|
|
|
describe('applyEmailDarkMode', () => {
|
|
const render = (body: string) => {
|
|
const frame = document.createElement('iframe');
|
|
document.body.appendChild(frame);
|
|
const doc = frame.contentDocument!;
|
|
doc.documentElement.className = DARK_PENDING_CLASS;
|
|
doc.documentElement.style.backgroundColor = 'rgb(20, 20, 20)';
|
|
doc.body.innerHTML = body;
|
|
applyEmailDarkMode(doc);
|
|
return doc;
|
|
};
|
|
|
|
// jsdom doesn't map presentational attributes (bgcolor) into computed
|
|
// style, so these use inline styles; browsers treat both the same.
|
|
it('darkens a white cell', () => {
|
|
const doc = render('<table><tr><td id="a" style="background-color:#fff">y</td></tr></table>');
|
|
const bg = c(doc.getElementById('a')!.style.getPropertyValue('background-color'));
|
|
expect(lightness(bg)).toBeLessThan(40);
|
|
});
|
|
|
|
it('makes dark text on a darkened cell readable', () => {
|
|
const doc = render('<div style="background-color:#fff"><p id="p" style="color:#222">text</p></div>');
|
|
const color = c(doc.getElementById('p')!.style.getPropertyValue('color'));
|
|
expect(contrastRatio(color, c('rgb(30, 30, 30)'))).toBeGreaterThan(4.5);
|
|
});
|
|
|
|
it('keeps a brand button and its label as sent', () => {
|
|
const doc = render('<a id="btn" style="background-color:#e30613;color:#ffffff">Buy</a>');
|
|
const btn = doc.getElementById('btn')!;
|
|
expect(btn.style.getPropertyPriority('background-color')).toBe('');
|
|
expect(btn.style.getPropertyPriority('color')).toBe('');
|
|
});
|
|
|
|
it('leaves text on a background image as sent', () => {
|
|
const doc = render('<div style="background-image:url(data:image/png;base64,AAAA);background-color:#ffffff"><p id="p" style="color:#111">On the photo</p></div>');
|
|
expect(doc.getElementById('p')!.style.getPropertyPriority('color')).toBe('');
|
|
});
|
|
|
|
it('converts a cell whose remote background the blocker stripped', () => {
|
|
const { html } = sanitizeEmailBodyForIframe(
|
|
'<table><tr><td id="hero" background="https://example.com/hero.jpg" style="background-color:#ffffff;background-image:url(https://example.com/hero.jpg)"><p id="p" style="color:#111">Headline</p></td></tr></table>',
|
|
true,
|
|
);
|
|
const doc = render(html);
|
|
expect(lightness(c(doc.getElementById('hero')!.style.getPropertyValue('background-color')))).toBeLessThan(40);
|
|
expect(doc.getElementById('p')!.style.getPropertyPriority('color')).toBe('important');
|
|
});
|
|
|
|
it('leaves the dark canvas behind a mail without colours alone', () => {
|
|
const frame = document.createElement('iframe');
|
|
document.body.appendChild(frame);
|
|
const doc = frame.contentDocument!;
|
|
doc.documentElement.style.backgroundColor = 'rgb(20, 20, 20)';
|
|
doc.body.style.backgroundColor = 'rgb(20, 20, 20)';
|
|
doc.body.innerHTML = '<p>Plain text</p>';
|
|
applyEmailDarkMode(doc);
|
|
expect(c(doc.defaultView!.getComputedStyle(doc.body).backgroundColor)).toEqual(c('rgb(20, 20, 20)'));
|
|
});
|
|
|
|
it('un-hides the body', () => {
|
|
const doc = render('<p>hi</p>');
|
|
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
|
|
});
|
|
|
|
// Newsletters (Lieferando) paint the canvas through `html { background }`.
|
|
// The pass only looked at the body, so that white stayed behind the mail
|
|
// and was taken for our dark canvas.
|
|
it('darkens a canvas the mail paints on <html>', () => {
|
|
const frame = document.createElement('iframe');
|
|
document.body.appendChild(frame);
|
|
const doc = frame.contentDocument!;
|
|
doc.documentElement.className = DARK_PENDING_CLASS;
|
|
doc.documentElement.style.backgroundColor = '#ffffff';
|
|
doc.body.style.backgroundColor = '#ffffff';
|
|
doc.body.innerHTML = '<p>hi</p>';
|
|
applyEmailDarkMode(doc);
|
|
const bgOf = (el: Element) => c(doc.defaultView!.getComputedStyle(el).backgroundColor);
|
|
expect(lightness(bgOf(doc.documentElement))).toBeLessThan(40);
|
|
expect(lightness(bgOf(doc.body))).toBeLessThan(40);
|
|
});
|
|
});
|
|
|
|
// #693: cells coloured through the legacy bgcolor attribute stayed white in
|
|
// dark mode. Browsers map bgcolor to background-color as a presentational hint
|
|
// (below every author style), which the pass reads back through computed
|
|
// style. jsdom has no presentational hints, so the helper below adds the same
|
|
// mapping as zero-specificity rules at the top of the cascade.
|
|
describe('applyEmailDarkMode with bgcolor', () => {
|
|
const mapBgcolorLikeBrowser = (doc: Document) => {
|
|
const rules: string[] = [];
|
|
doc.querySelectorAll('[bgcolor]').forEach((el, i) => {
|
|
el.setAttribute('data-bgcolor-hint', String(i));
|
|
rules.push(`:where([data-bgcolor-hint="${i}"]) { background-color: ${el.getAttribute('bgcolor')}; }`);
|
|
});
|
|
const sheet = doc.createElement('style');
|
|
sheet.textContent = rules.join('\n');
|
|
doc.head.prepend(sheet);
|
|
};
|
|
|
|
const render = (mail: string, bodyBgcolor?: string) => {
|
|
const { html } = sanitizeEmailBodyForIframe(mail, true);
|
|
const frame = document.createElement('iframe');
|
|
document.body.appendChild(frame);
|
|
const doc = frame.contentDocument!;
|
|
doc.documentElement.className = DARK_PENDING_CLASS;
|
|
doc.documentElement.style.backgroundColor = 'rgb(20, 20, 20)';
|
|
if (bodyBgcolor) doc.body.setAttribute('bgcolor', bodyBgcolor);
|
|
doc.body.innerHTML = html;
|
|
mapBgcolorLikeBrowser(doc);
|
|
applyEmailDarkMode(doc);
|
|
return doc;
|
|
};
|
|
|
|
// The darkened colour the pass wrote over the sender's one.
|
|
const rewrittenBg = (el: HTMLElement) => {
|
|
expect(el.style.getPropertyPriority('background-color')).toBe('important');
|
|
return c(el.ownerDocument.defaultView!.getComputedStyle(el).backgroundColor);
|
|
};
|
|
|
|
it.each([
|
|
['td', '<table><tr><td id="x" bgcolor="#ffffff"><p id="p" style="color:#333333">Hello</p></td></tr></table>'],
|
|
['tr', '<table><tr id="x" bgcolor="#ffffff"><td><p id="p" style="color:#333333">Hello</p></td></tr></table>'],
|
|
['table', '<table id="x" bgcolor="#ffffff" width="100%"><tr><td><p id="p" style="color:#333333">Hello</p></td></tr></table>'],
|
|
['td with a named colour', '<table><tr><td id="x" bgcolor="white"><p id="p" style="color:#333333">Hello</p></td></tr></table>'],
|
|
['td with a light tint', '<table><tr><td id="x" bgcolor="#f4f4f4"><p id="p" style="color:#333333">Hello</p></td></tr></table>'],
|
|
])('darkens a light bgcolor on %s and keeps its text readable', (_, mail) => {
|
|
const doc = render(mail);
|
|
const bg = rewrittenBg(doc.getElementById('x')!);
|
|
expect(lightness(bg)).toBeLessThan(40);
|
|
const text = c(doc.defaultView!.getComputedStyle(doc.getElementById('p')!).color);
|
|
expect(contrastRatio(text, bg)).toBeGreaterThan(4.5);
|
|
});
|
|
|
|
it('darkens the inline background when a cell has both', () => {
|
|
const doc = render('<table><tr><td id="x" bgcolor="#000000" style="background-color:#ffffff">Hello</td></tr></table>');
|
|
expect(lightness(rewrittenBg(doc.getElementById('x')!))).toBeLessThan(40);
|
|
});
|
|
|
|
it('keeps a vivid bgcolor as sent', () => {
|
|
const doc = render('<table><tr><td id="x" bgcolor="#e30613" style="color:#ffffff">Sale</td></tr></table>');
|
|
const cell = doc.getElementById('x')!;
|
|
expect(c(doc.defaultView!.getComputedStyle(cell).backgroundColor)).toEqual(c('rgb(227, 6, 19)'));
|
|
expect(cell.style.getPropertyValue('background-color')).toBe('');
|
|
});
|
|
|
|
it('darkens a white body bgcolor', () => {
|
|
const doc = render('<p id="p" style="color:#222222">Hello</p>', '#ffffff');
|
|
expect(lightness(rewrittenBg(doc.body))).toBeLessThan(40);
|
|
});
|
|
});
|