waybetter-for-x/apps/desktop/tests/themeInvariants.test.ts

103 lines
7 KiB
TypeScript

// Theme invariants (waybetter runtime themeInvariants.ts) on hand-built DOM in Chromium: each invariant
// fails on its smallest bad shape and passes on the good one. The shapes are the owner's reported bugs.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
import { chromium, type Browser, type Page } from 'playwright';
const src = fileURLToPath(new URL('../../../waybetter/packages/runtime/src/themeInvariants.ts', import.meta.url));
let browser: Browser, page: Page, bundle: string;
before(async () => {
bundle = (await build({ entryPoints: [src], bundle: true, format: 'iife', globalName: 'WBInv', write: false, target: 'chrome120' })).outputFiles[0].text;
browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined });
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
});
after(async () => { await browser?.close(); });
async function run(body: string, opts: Record<string, unknown> = {}) {
await page.setContent(`<!doctype html><html><body style="margin:0;background:#fff;font:15px sans-serif">${body}</body></html>`);
await page.addScriptTag({ content: bundle });
return page.evaluate(o => (window as unknown as { WBInv: { runInvariants: (d: Document, o: unknown) => { counts: Record<string, number>; findings: { id: string; detail: string }[] } } }).WBInv.runInvariants(document, o), opts);
}
const BAR = (items: string) => `<header role="banner"><nav role="navigation" style="display:flex;gap:0">${items}</nav></header>`;
test('I1: the "New" badge, navy on indigo, fails; white on black passes', async () => {
const bad = await run('<p style="background:rgb(30,27,75);color:rgb(0,0,168);padding:4px">New</p>');
assert.equal(bad.counts.I1, 1);
assert.match(bad.findings[0].detail, /1\.1\d:1/);
assert.equal((await run('<p style="background:#000;color:#fff">New</p>')).counts.I1, 0);
});
test('I1: the HoverLabel, black on grey 82, fails; large text uses 3:1', async () => {
assert.equal((await run('<div data-testid="HoverLabel" style="background:rgb(82,82,82);color:#000;width:80px">More</div>')).counts.I1, 1);
assert.equal((await run('<h1 style="font-size:28px;color:rgb(130,130,130)">Big</h1>')).counts.I1, 0);
assert.equal((await run('<h1 style="font-size:28px;color:rgb(130,130,130)">Big</h1>', { access: 'high' })).counts.I1, 1);
});
test('I1b: a faint icon fails', async () => {
const svg = (c: string) => `<svg width="24" height="24" viewBox="0 0 24 24" style="color:${c}"><path fill="currentColor" d="M0 0h24v24H0z"/></svg>`;
assert.equal((await run(svg('rgb(238,238,238)'))).counts.I1b, 1);
assert.equal((await run(svg('rgb(40,40,40)'))).counts.I1b, 0);
});
test('I2: X blue left on a themed page fails, unless the theme is X default', async () => {
const html = '<div style="background:rgb(29,155,240);width:200px;height:40px">Follow</div>';
assert.equal((await run(html)).counts.I2, 1);
assert.equal((await run(html, { exemptLeak: true })).counts.I2, 0);
});
test('I3: a bar label cut to "H…" fails; a whole label passes; a long user name is allowed', async () => {
const label = (w: number) => `<a role="link" style="display:block;width:${w}px"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Notifications</span></a>`;
assert.equal((await run(BAR(label(24)))).counts.I3, 1);
assert.equal((await run(BAR(label(200)))).counts.I3, 0);
const name = await run('<button><div data-testid="User-Name" style="width:30px"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Averylongname</span></div></button>');
assert.equal(name.counts.I3, 0);
});
test('I4: a horizontal bar with uneven items fails; equal items pass', async () => {
const item = (w: number, t: string) => `<a role="link" style="display:block;width:${w}px;height:40px"><span>${t}</span></a>`;
assert.ok((await run(BAR(item(80, 'Home') + item(140, 'Explore') + item(80, 'Chat')))).counts.I4 >= 1);
assert.equal((await run(BAR(item(100, 'Home') + item(100, 'Explore') + item(100, 'Chat')))).counts.I4, 0);
});
test('I4: an item covered by another element fails the hit test', async () => {
const item = (t: string) => `<a role="link" style="display:block;width:100px;height:40px"><span>${t}</span></a>`;
const r = await run(`${BAR(item('Home') + item('Explore') + item('Chat'))}<div style="position:fixed;left:0;top:0;width:320px;height:60px;background:#fff"></div>`);
assert.equal(r.counts.I4, 3);
});
test('I5: X line icons in the rail fail under a pack; masked glyphs pass', async () => {
const xIcon = '<a role="link"><svg width="26" height="26" viewBox="0 0 24 24"><path d="M12 3l9 8H3z"/></svg></a>';
const packed = '<a role="link"><svg width="26" height="26" viewBox="0 0 24 24" style="-webkit-mask:url(data:image/svg+xml,%3Csvg/%3E);background:#000"><path style="opacity:0" d="M12 3l9 8H3z"/></svg></a>';
assert.equal((await run(BAR(xIcon), { pack: true })).counts.I5, 1);
assert.equal((await run(BAR(packed), { pack: true })).counts.I5, 0);
assert.equal((await run(BAR(xIcon), { pack: false })).counts.I5, 0);
});
test('I6: the empty grey sidebar box fails; the same box with content passes', async () => {
assert.equal((await run('<div data-testid="sidebarColumn" style="width:350px"><section style="background:#c0c0c0;border:2px inset #fff;height:260px"></section></div>')).counts.I6, 1);
assert.equal((await run('<div style="width:350px"><section style="background:#c0c0c0;border:2px inset #fff;height:260px">Who to follow</section></div>')).counts.I6, 0);
});
test('I7: a frame around the whole sidebar column fails', async () => {
const r = await run('<div style="border:1px solid #888;background:#eee;width:400px"><div data-testid="sidebarColumn" style="height:100px">x</div></div>');
assert.equal(r.counts.I7, 1);
});
test('I8: the account menu 52px past the right edge and a HoverCard cut at the bottom fail', async () => {
const menu = await run('<div role="menu" style="position:fixed;left:1100px;top:100px;width:232px;height:200px;background:#fff;border:1px solid #888">Add an existing account</div>');
assert.equal(menu.counts.I8, 1);
const card = await run('<div data-testid="HoverCard" style="position:fixed;left:100px;top:700px;width:300px;height:240px;background:#fff">Profile</div>');
assert.equal(card.counts.I8, 1);
assert.equal((await run('<div role="menu" style="position:fixed;left:100px;top:100px;width:232px;height:200px;background:#fff">Menu</div>')).counts.I8, 0);
});
test('I10: a 16px bar target fails; 44px is needed with access high', async () => {
const item = (s: number) => `<a role="link" style="display:block;width:${s}px;height:${s}px">.</a>`;
assert.equal((await run(BAR(item(16) + item(16) + item(16)))).counts.I10, 3);
assert.equal((await run(BAR(item(32) + item(32) + item(32)))).counts.I10, 0);
assert.equal((await run(BAR(item(32) + item(32) + item(32)), { access: 'high' })).counts.I10, 3);
});