unroll / tests /coverage.mjs
SharpMeow's picture
Public API at window.unroll (schema, help, coverage test; ui and bench groups), embed mode with postMessage bridge and oEmbed, version 0.9.4 in About and API, ink_9um (9 µm) label and credit, Ink Bench hardening from the audit, hashchange without reload, capped test hooks
cc6b6d8
Raw History Blame Contribute Delete
7.08 kB
// API coverage test: every interactive element in every UI state must carry a unique data-testid
// and a data-api naming a window.unroll method that exists. Exit code 1 on any miss.
// Usage: node tests/coverage.mjs [baseURL] (needs playwright-core and a Chrome; CHROME=/path)
import { chromium } from 'playwright-core';
const BASE = process.argv[2] || 'http://localhost:8080/';
const SEL = 'button, a[href], input, select, textarea, summary, [role=button], [role=tab], [role=slider], [tabindex]:not([tabindex="-1"]), canvas, label:has(input)';
const browser = await chromium.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(180000); // shared CI boxes can be slow; real browsers are far faster
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(String(e)));
const seen = new Map(); const misses = [];
async function audit(state, scope = 'body') {
const els = await page.evaluate(([SEL, scope]) => {
const resolve = (path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), window.unroll);
return [...document.querySelector(scope).querySelectorAll(SEL)].map((el) => {
const api = el.dataset.api || '';
const path = api.split('(')[0].trim();
return { tag: el.tagName.toLowerCase(), id: el.id, text: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40), testid: el.dataset.testid || '', api, ok: !!path && typeof resolve(path) === 'function', named: !!(el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || (el.textContent || '').trim() || el.closest('label')) };
});
}, [SEL, scope]);
for (const e of els) {
const key = e.testid || `${state}:${e.tag}#${e.id}:${e.text}`;
if (!e.testid) misses.push(`[${state}] ${e.tag}#${e.id} "${e.text}": no data-testid`);
if (!e.ok) misses.push(`[${state}] ${e.testid || e.tag}: data-api "${e.api}" does not resolve to a window.unroll method`);
if (!e.named) misses.push(`[${state}] ${e.testid || e.tag}: no accessible name`);
if (!seen.has(key)) seen.set(key, { ...e, state });
}
const dup = await page.evaluate(() => { const c = {}; for (const el of document.querySelectorAll('[data-testid]')) c[el.dataset.testid] = (c[el.dataset.testid] || 0) + 1; return Object.keys(c).filter((k) => c[k] > 1); });
for (const d of dup) misses.push(`[${state}] duplicate data-testid "${d}"`);
}
const u = (code) => page.evaluate(code);
await page.goto(BASE, { waitUntil: 'load' });
await page.waitForFunction(() => window.unroll);
await u(() => window.unroll.waitFor('ready', { timeout: 60000 }));
await audit('initial');
await u(() => window.unroll.panel('about')); await audit('about');
await u(() => window.unroll.panel('help')); await audit('help');
await u(() => window.unroll.panel('embed')); await audit('embed-panel');
await u(() => window.unroll.panel('open')); await audit('open-own');
await u(() => window.unroll.panel('open', false));
await u(() => window.unroll.destroy.mode(true)); await audit('destroy');
await u(() => window.unroll.destroy.sandbox(true)); await audit('destroy-sandbox');
await u(() => window.unroll.destroy.sandbox(false));
await u(() => window.unroll.destroy.mode(false));
await u(async () => { const s = await window.unroll.getState(); const ov = s.layers.available.find((l) => l.id === 'overlay'); const r = await window.unroll.focus(ov ? ov.windings[0] : undefined, ov ? { view: 'compare' } : {}); if (!r.ok) throw new Error(r.error); });
await audit('closeup');
await u(() => window.unroll.focus(null));
// schema and implementation agree, including add-ons such as bench
const api = await u(async () => {
const sch = (await window.unroll.schema()).methods;
const resolve = (path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), window.unroll);
return { missing: Object.keys(sch).filter((k) => typeof resolve(k) !== 'function'), count: Object.keys(sch).length, extensions: (await window.unroll.schema()).extensions, bench: !!window.unroll.bench, benchMethods: window.unroll.bench ? Object.keys(window.unroll.bench) : [] };
});
for (const m of api.missing) misses.push(`schema lists ${m} but window.unroll has no such function`);
for (const m of api.benchMethods) if (typeof (await u((m) => typeof window.unroll.bench[m], m)) !== 'string') misses.push(`bench.${m} unreachable`);
// a scroll opened from file bytes: its saved-list buttons and the mesh scroll UI
await u(async () => { const ex = await window.unroll.open.example(); window.unroll.open.fromFiles([{ name: ex.name, data: ex.base64 }]); });
await page.waitForURL(/s=local-/, { timeout: 60000 });
await page.waitForFunction(() => window.unroll);
await u(() => window.unroll.waitFor('ready', { timeout: 60000 }));
await audit('local-scroll');
await u(() => window.unroll.panel('open'));
await page.waitForSelector('[data-testid^="open-saved-open-"]', { timeout: 10000 }).catch(() => {});
await audit('open-own-saved');
// load-error state: the recovery buttons
const errorsBefore = pageErrors.length;
await page.goto(BASE + '?scroll=local-missing', { waitUntil: 'load' });
await page.waitForSelector('[data-testid="load-back"]', { state: 'attached', timeout: 60000 });
await audit('load-error', '#loader'); // the rest of the page sits behind the error screen
// the load failure itself is expected here
pageErrors.splice(errorsBefore).filter((e) => !/removed from this one/.test(e)).forEach((e) => pageErrors.push(e));
// clean up the stored example
await page.goto(BASE, { waitUntil: 'load' });
await page.waitForFunction(() => window.unroll && window.unroll.open);
await u(async () => { for (const s of (await window.unroll.open.listLocal()).scrolls) await window.unroll.open.removeLocal(s.key); });
const MUST = ['destroy-tool-burn', 'destroy-tool-water', 'destroy-sandbox', 'roll-up', 'embed', 'embed-copy', 'embed-code', 'fullscreen', 'embed-activate', 'embed-open-full', 'view-3d', 'layer-ink', 'unroll-slider', 'closeup-tab-compare', 'closeup-tab-overlay', 'compare-slider', 'load-back', 'load-retry', 'help', 'help-close'];
for (const m of MUST) if (![...seen.values()].some((e) => e.testid === m)) misses.push(`expected control ${m} was never seen`);
if (![...seen.values()].some((e) => e.testid.startsWith('open-saved-open-'))) misses.push('saved scroll buttons were never seen');
await browser.close();
const n = seen.size;
console.log(JSON.stringify({ elements: n, states: ['initial', 'about', 'help', 'embed-panel', 'open-own', 'destroy', 'destroy-sandbox', 'closeup', 'local-scroll', 'open-own-saved', 'load-error'], apiMethods: api.count, extensions: api.extensions, bench: api.bench ? api.benchMethods : 'not present (hook ready)', covered: `${[...seen.values()].filter((e) => e.testid && e.ok).length}/${n}`, testids: [...seen.values()].map((e) => `${e.testid} -> ${e.api}`), misses, pageErrors }, null, 1));
process.exit(misses.length || pageErrors.length ? 1 : 0);