// accessibility: axe-core on each widget closed and open, plus keyboard only: reach the launcher with Tab, open it with // Enter, find the message box, type, close with Escape, see where focus lands. // bun run --cwd tools/widget-lab accessibility // axe runs on the widget only: its root element in the page (open shadow roots included) and every iframe it adds. // We never send a message: typing stops before Enter. import { createRequire } from "node:module"; import type { Frame, Page } from "playwright-core"; import { chrome, env, find, RUNS, save } from "./lab"; import { serve, url } from "./serve"; import { WIDGETS, type Widget } from "./widgets"; const AXE = await Bun.file(createRequire(import.meta.url).resolve("axe-core/axe.min.js")).text(); const AXE_VERSION = (await Bun.file(createRequire(import.meta.url).resolve("axe-core/package.json")).json()).version; /** an accessible name that says "this opens the chat" (Spookat "Open chat", Tawk.to "Chat widget", Tidio "Open chat widget", ...) */ const OPENER = /open|chat widget|launcher|messenger/i; const WIDGETISH = /tawk|crisp|intercom|tidio|chat[- ]widget|chatwoot|woot|livechat|spookat/i; const only = process.env.ONLY?.split(","); async function isWidgetFrame(f: Frame | null): Promise { if (!f || !f.parentFrame()) return false; const el = await f.frameElement().catch(() => null); const attrs = el && (await el.evaluate((e: Element) => `${e.id} ${e.getAttribute("name")} ${e.getAttribute("title")} ${e.getAttribute("src")} ${e.className}`).catch(() => "")); return WIDGETISH.test(attrs ?? "") || isWidgetFrame(f.parentFrame()); } type Violation = { id: string; impact: string | null; help: string; wcag: string[]; nodes: number; targets: string[] }; async function axe(page: Page, w: Widget) { const out = new Map(); for (const f of page.frames()) { const main = !f.parentFrame(); if (!main && !(await isWidgetFrame(f))) continue; const res = await f .evaluate(async ([src, root, main]) => { if (!(window as { axe?: unknown }).axe) (0, eval)(src); const include = main ? root.split(",").map((s) => s.trim()).filter((s) => document.querySelector(s)) : null; if (main && !include!.length) return []; const axe = (window as unknown as { axe: { run(c: unknown, o: unknown): Promise<{ violations: { id: string; impact: string | null; help: string; tags: string[]; nodes: { target: unknown[] }[] }[] }> } }).axe; // axe run from the top page checks these on the top document only (is-initiator-matches, pageLevel) or passes // them when any frame has one (one main, an h1): in a widget's iframe they'd flag the frame for not being a page const off = main ? [] : ["aria-hidden-body", "document-title", "html-has-lang", "meta-viewport", "meta-viewport-large", "bypass", "landmark-one-main", "page-has-heading-one"]; const r = await axe.run(main ? { include: include!.map((s) => [s]) } : document, { resultTypes: ["violations"], iframes: false, // every widget frame gets its own run rules: Object.fromEntries(off.map((id) => [id, { enabled: false }])), }); return r.violations.map((v) => ({ id: v.id, impact: v.impact, help: v.help, wcag: v.tags.filter((t) => /^wcag\d/.test(t)), nodes: v.nodes.length, targets: v.nodes.slice(0, 3).map((n) => JSON.stringify(n.target)) })); }, [AXE, w.root, main] as const) .catch((e) => [{ id: "axe-error", impact: null, help: String(e).slice(0, 120), wcag: [], nodes: 0, targets: [] }]); for (const v of res) { const had = out.get(v.id); out.set(v.id, had ? { ...had, nodes: had.nodes + v.nodes, targets: [...had.targets, ...v.targets].slice(0, 5) } : v); } } return [...out.values()].filter((v) => v.id !== "axe-error" || !/detached|destroyed/i.test(v.help)); } /** the focused element, through iframes and open shadow roots, and whether it belongs to the widget */ async function focused(page: Page, w: Widget) { for (const f of page.frames()) { const d = await f .evaluate((root) => { if (!document.hasFocus()) return null; let e = document.activeElement as HTMLElement | null; while (e?.shadowRoot?.activeElement) e = e.shadowRoot.activeElement as HTMLElement; if (!e || e.tagName === "IFRAME" || e === document.body) return null; let n: Node | null = e; let inRoot = false; while (n) { if (n instanceof Element && root.split(",").some((s) => n instanceof Element && n.matches(s.trim()))) inRoot = true; n = n.parentNode ?? (n as ShadowRoot).host ?? null; } const label = e.getAttribute("aria-label") ?? e.getAttribute("placeholder") ?? e.getAttribute("title") ?? e.innerText?.slice(0, 40) ?? ""; return { tag: e.tagName.toLowerCase(), label: label.replace(/\s+/g, " ").trim(), typing: ["INPUT", "TEXTAREA"].includes(e.tagName) || e.isContentEditable, inRoot }; }, w.root) .catch(() => null); if (d) return { ...d, inWidget: d.inRoot || (await isWidgetFrame(f)) }; } return null; } async function keyboard(page: Page, w: Widget) { // widgets sit at the end of the page: Shift+Tab from the top gets there first. Then forward Tab, the long way round. const walk = async (key: string, max: number) => { await page.mouse.click(2, 2); // focus the page itself, top-left corner, nothing there for (let n = 1; n <= max; n++) { await page.keyboard.press(key); const f = await focused(page, w); if (f?.inWidget) return { n, f }; } return null; }; const back = await walk("Shift+Tab", 30); const fwd = await walk("Tab", 400); const f = (fwd ?? back)?.f ?? null; const reached = !!f; const r: Record = { tabsToWidget: fwd?.n ?? null, shiftTabsToWidget: back?.n ?? null, firstStop: f }; if (!reached) return r; // the widget's own stops from there, until one reads like the thing that opens the chat (max 12) const path = [f]; let g: Awaited> = f; while (!OPENER.test(g?.label ?? "") && path.length < 12) { await page.keyboard.press("Tab"); g = await focused(page, w); if (!g?.inWidget) break; path.push(g); } r.path = path.map((x) => `${x?.tag}: ${x?.label}`); if (!OPENER.test(g?.label ?? "") || !g?.inWidget) return r; await page.keyboard.press("Enter"); let ready = null; for (const t = Date.now(); !ready && Date.now() - t < 10_000; ) (await page.waitForTimeout(250), (ready = await find(page, w.ready))); r.opensWithEnter = !!ready; if (!ready) return r; await page.waitForTimeout(1000); r.focusAfterOpen = await focused(page, w); g = r.focusAfterOpen as Awaited>; let more = 0; for (; !g?.typing && more < 20; more++) (await page.keyboard.press("Tab"), (g = await focused(page, w))); r.tabsToMessageBox = g?.typing ? more : null; if (g?.typing) { await page.keyboard.type("hello", { delay: 30 }); r.typed = await (await find(page, w.ready))?.evaluate((e) => ((e as HTMLInputElement).value ?? e.textContent ?? "").includes("hello")).catch(() => false) ?? false; } await page.keyboard.press("Escape"); await page.waitForTimeout(1500); r.closesWithEscape = !(await find(page, w.ready)); r.focusAfterEscape = await focused(page, w); return r; } /** load the page in a fresh profile and wait for the launcher, then SETTLE ms */ async function load(w: Widget, target: string, settle: number) { const b = await chrome(); await b.page.goto(target, { waitUntil: "load", timeout: 60_000 }); const t = Date.now(); let launcher = await find(b.page, w.launcher); while (!launcher && Date.now() - t < 30_000) (await b.page.waitForTimeout(250), (launcher = await find(b.page, w.launcher))); if (launcher) await b.page.waitForTimeout(settle); return { ...b, launcher }; } async function once(w: Widget | undefined, target: string) { if (!w) { const { page, close } = await chrome(); await page.goto(target, { waitUntil: "load", timeout: 60_000 }); const closed = await axe(page, { root: "body" } as Widget); // the test page itself: must be clean await close(); return { closed }; } // closed + keyboard let b = await load(w, target, 5000); if (!b.launcher) return await b.close(), { error: "no launcher in 30 s" }; const closed = await axe(b.page, w).finally(() => undefined); const kb = await keyboard(b.page, w).catch((e) => ({ error: String(e).slice(0, 160) })); await b.close(); // open, in a fresh profile (the keyboard run may have left a chat open, and hundreds of Tab presses a vendor's menu), // clicked with the mouse like in the click test b = await load(w, target, 15_000); try { if (!b.launcher) return { closed, open: null, keyboard: kb, openError: "no launcher in 30 s" }; const box = await b.launcher.boundingBox(); if (box) await b.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); let ready = null; for (const t1 = Date.now(); !ready && Date.now() - t1 < 10_000; ) (await b.page.waitForTimeout(250), (ready = await find(b.page, w.ready))); await b.page.waitForTimeout(3000); const open = ready ? await axe(b.page, w) : null; return { closed, open, keyboard: kb }; } finally { await b.close(); } } const server = serve(); const started = new Date().toISOString(); const results: Record = {}; for (const w of [undefined, ...WIDGETS]) { const id = w?.id ?? "blank"; if (only && !only.includes(id)) continue; const target = !w || w.boots ? url(w?.id) : w.home; if (!target) continue; const runs = []; for (let i = 0; i < RUNS; i++) { const r = await once(w, target).catch((e) => ({ error: String(e).slice(0, 160) })); runs.push({ at: new Date().toISOString(), ...r }); console.log(id, i + 1, JSON.stringify(r).slice(0, 400)); } results[id] = { widget: w?.name ?? null, where: !w || w.boots ? "test page" : "vendor page", url: target, runs }; } server.stop(true); save("accessibility", { test: "axe-core and keyboard", started, finished: new Date().toISOString(), runs: RUNS, env: { ...(await env()), axeCore: AXE_VERSION }, method: [ "Headed Google Chrome, 1280x800, a fresh profile per run. axe-core with its default rules (WCAG 2.0/2.1/2.2 A and AA plus best practices), violations only.", "Scope: the widget alone, i.e. its root element in the page (open shadow roots included) and every iframe it adds, each iframe in its own run with the rules axe applies to the top document only switched off (aria-hidden-body, document-title, html-has-lang, meta-viewport, meta-viewport-large, bypass, landmark-one-main, page-has-heading-one), the way axe treats frames when it runs from the top page. The test page alone has no violations (blank).", "Closed: 5 s after the launcher shows. Open: a second fresh profile, 15 s after the launcher shows it is clicked with the mouse, axe runs 3 s after the widget's first usable element showed.", "Keyboard: click the empty top-left corner of the page, press Shift+Tab until focus is inside the widget (max 30), then again from the corner with Tab (max 400); inside the widget, Tab on until a control's accessible name says it opens the chat (open, chat widget, launcher, messenger; max 12 stops, all recorded), Enter, wait for the chat to be usable, note where focus is, Tab until a text field has focus (max 20), type 'hello' (never Enter: nothing is sent), Escape, note whether it closed and where focus went.", "Spookat, Tawk.to and Tidio on our test page; Crisp, Intercom, LiveChat and Chatwoot on the vendor's own page (they refuse our domain), where the Tab count includes that page's own links.", ], results, });