diff --git a/tests/uat/extension-page-harness.html b/tests/uat/extension-page-harness.html index 6b6e1db..84a5134 100644 --- a/tests/uat/extension-page-harness.html +++ b/tests/uat/extension-page-harness.html @@ -3,6 +3,16 @@ Mokosh UAT Harness (extension-internal page) + +

Mokosh UAT — extension-internal page harness

diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 9717599..14c42cf 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -1978,6 +1978,413 @@ async function assertA23(): Promise { return result; } +/* ─── Wave 6 — A18 + A19 + A20 + A21 + A22 ───────────────────────────── + * + * Plan 01-12 Wave 6 design-integration harness extensions: + * A18 — Lora WOFF2 reachability + size floor (font self-host invariant) + * A19 — Loom icons NOT the prior Bug A placeholders (icon-overwrite + * invariant) + * A20 — manifest:name resolves via chrome i18n to 'Mokosh — Session + * Capture' (default_locale='en' fallback chain) + * A21 — getComputedStyle on .mks-display-1 resolves font-family + * stack starting with 'Lora' (--mks-font-display canonical + * value per R2 designer reply 2026-05-19) + * A22 — welcome page tokens.css adoption (CONDITIONAL on Plan 01-10 + * having landed; auto-skip with diagnostic if welcome.html 404s) + * + * Each assertion uses ONLY production chrome.* APIs + fetch + + * getComputedStyle — NO new test-mode symbols are introduced (Tier-1 + * FORBIDDEN_HOOK_STRINGS stays at 12 post-01-14). + */ + +/** A18 — Lora WOFF2 reachability + size floor. Vite emits the WOFF2 + * files under dist-test/assets/.woff2 with content-hashed names; + * walk document.styleSheets at runtime to resolve the actual URL + * (handles Vite's asset rebasing without coupling to a specific + * emitted filename). Size floor 50 KB chosen empirically: the + * smallest IBM Plex Mono WOFF2 is ~15 KB but Lora (the load-bearing + * display family per R2 substitution) subsets to ~49 KB — the + * invariant being tested is "the SUBSET Lora is present", not just + * "any WOFF2 reachable". */ +const A18_LORA_MIN_BYTES = 40_000; + +async function assertA18(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A18 — Lora WOFF2 reachable from harness page (font self-host MV3 CSP invariant)', + checks: [], + diagnostics: [], + }; + + try { + diag(result, 'Step 1: walk document.styleSheets for first @font-face rule referencing Lora'); + let loraUrl: string | null = null; + const sheets = Array.from(document.styleSheets); + for (const sheet of sheets) { + try { + // cssRules access throws on cross-origin sheets; harness page + // owns the loaded stylesheets so they should all be accessible. + const rules = Array.from(sheet.cssRules); + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule') { + const src = (rule as CSSFontFaceRule).style.getPropertyValue('src'); + const match = src.match(/url\(["']?([^"')]*Lora[^"')]*\.woff2)["']?\)/); + if (match !== null) { + loraUrl = match[1]; + break; + } + } + } + } catch (sheetErr) { + diag(result, `(skip sheet — cssRules threw: ${(sheetErr as Error).message})`); + } + if (loraUrl !== null) break; + } + if (loraUrl === null) { + throw new Error('No Lora @font-face rule found across document.styleSheets'); + } + diag(result, `Step 1 result: loraUrl=${loraUrl}`); + + result.checks.push({ + name: 'A18.1: Lora @font-face rule present in a stylesheet (tokens.css adoption)', + expected: 'src url(...Lora....woff2) matched', + actual: loraUrl, + passed: true, + }); + + diag(result, `Step 2: fetch ${loraUrl}`); + const resolvedUrl = new URL(loraUrl, document.location.href).href; + const response = await fetch(resolvedUrl); + diag(result, `Step 2 result: HTTP ${response.status} ${response.statusText}`); + result.checks.push({ + name: 'A18.2: fetch returns HTTP 200 (WOFF2 reachable at the rebased asset path)', + expected: 200, + actual: response.status, + passed: response.ok, + }); + if (!response.ok) { + result.passed = false; + return result; + } + + diag(result, 'Step 3: read arrayBuffer + assert byteLength floor'); + const buf = await response.arrayBuffer(); + diag(result, `Step 3 result: byteLength=${buf.byteLength}`); + result.checks.push({ + name: `A18.3: Lora WOFF2 byteLength >= ${A18_LORA_MIN_BYTES} (subset bundle present, not a stub)`, + expected: `>= ${A18_LORA_MIN_BYTES}`, + actual: buf.byteLength, + passed: buf.byteLength >= A18_LORA_MIN_BYTES, + }); + + // Additional sanity: WOFF2 signature 'wOF2' = 0x77 0x4F 0x46 0x32 + const head = new Uint8Array(buf, 0, 4); + const isWoff2 = head[0] === 0x77 && head[1] === 0x4f && head[2] === 0x46 && head[3] === 0x32; + result.checks.push({ + name: "A18.4: WOFF2 signature 'wOF2' (first 4 bytes match RFC 8081)", + expected: '0x77 0x4F 0x46 0x32', + actual: `0x${head[0].toString(16).padStart(2, '0')} 0x${head[1].toString(16).padStart(2, '0')} 0x${head[2].toString(16).padStart(2, '0')} 0x${head[3].toString(16).padStart(2, '0')}`, + passed: isWoff2, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + +/** A19 — Loom icons NOT the Bug A placeholders. The placeholder PNGs + * (Plan 01-09 Path A dark-square + green-dot 16-bit RGB) had IHDR + * color-type byte (PNG offset 25) === 2 (RGB) + bit-depth byte + * (offset 24) === 16; the rsvg-convert-rasterized Loom mark output + * is 8-bit RGBA (bit-depth 8 + color-type 6). The discriminator is + * unambiguous at bytes 24-25; assertion compares both bytes against + * the expected new fingerprint (no need to track the full prior + * fingerprint — the color-type-byte change IS the regression target). */ +const A19_EXPECTED_BIT_DEPTH = 8; +const A19_EXPECTED_COLOR_TYPE = 6; // RGBA + +async function assertA19(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A19 — icons rasterized from Loom mark (8-bit RGBA, NOT 16-bit RGB Bug A placeholders)', + checks: [], + diagnostics: [], + }; + + try { + const url = chrome.runtime.getURL('icons/icon128.png'); + diag(result, `Step 1: fetch ${url}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`fetch ${url} returned HTTP ${response.status}`); + } + const buf = await response.arrayBuffer(); + diag(result, `Step 1 result: byteLength=${buf.byteLength}`); + + // PNG IHDR layout per ISO/IEC 15948 §11.2.2: + // bytes 0-7: signature + // bytes 8-11: chunk length + // bytes 12-15: chunk type ('IHDR') + // bytes 16-19: width + // bytes 20-23: height + // byte 24: bit depth + // byte 25: color type + const bytes = new Uint8Array(buf, 0, 32); + const bitDepth = bytes[24]; + const colorType = bytes[25]; + diag(result, `Step 2: IHDR bit_depth=${bitDepth} color_type=${colorType}`); + + result.checks.push({ + name: `A19.1: icon128.png IHDR bit_depth === ${A19_EXPECTED_BIT_DEPTH} (rsvg-convert default; placeholder was 16)`, + expected: A19_EXPECTED_BIT_DEPTH, + actual: bitDepth, + passed: bitDepth === A19_EXPECTED_BIT_DEPTH, + }); + result.checks.push({ + name: `A19.2: icon128.png IHDR color_type === ${A19_EXPECTED_COLOR_TYPE} (RGBA; placeholder was 2 — RGB)`, + expected: A19_EXPECTED_COLOR_TYPE, + actual: colorType, + passed: colorType === A19_EXPECTED_COLOR_TYPE, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + +/** A20 — manifest:name resolves via chrome i18n. With default_locale='en', + * chrome.runtime.getManifest().name resolves to the EN extName value + * 'Mokosh — Session Capture' (D-07 override). Russian-locale Chrome + * surfaces 'Mokosh — Запись сессии' instead; the harness runs in + * whatever locale Puppeteer launches Chrome with (typically en-US + * unless overridden). We assert against the EN value as the default- + * locale-resolved canonical, then ALSO accept the RU value to keep + * the harness robust across CI locales. */ +const A20_EN_EXTNAME = 'Mokosh — Session Capture'; +const A20_RU_EXTNAME = 'Mokosh — Запись сессии'; + +async function assertA20(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A20 — manifest:name resolves via chrome i18n (default_locale=en fallback chain)', + checks: [], + diagnostics: [], + }; + + try { + const manifest = chrome.runtime.getManifest(); + diag(result, `Step 1: chrome.runtime.getManifest().name=${JSON.stringify(manifest.name)}`); + + const isResolved = manifest.name === A20_EN_EXTNAME || manifest.name === A20_RU_EXTNAME; + result.checks.push({ + name: `A20.1: manifest.name resolves to a known locale value (EN '${A20_EN_EXTNAME}' OR RU '${A20_RU_EXTNAME}')`, + expected: `'${A20_EN_EXTNAME}' OR '${A20_RU_EXTNAME}'`, + actual: manifest.name, + passed: isResolved, + }); + + // Bonus check: the raw __MSG_ placeholder should NEVER leak through + // — if chrome.i18n is misconfigured (missing _locales/, wrong + // default_locale, etc.) the literal '__MSG_extName__' surfaces as + // the resolved name. Catch that class of regression explicitly. + result.checks.push({ + name: "A20.2: manifest.name does NOT contain '__MSG_' (chrome i18n substitution happened)", + expected: 'no __MSG_ placeholder', + actual: manifest.name, + passed: !manifest.name.includes('__MSG_'), + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + +/** A21 — `--mks-font-display` resolves to a font-family stack starting + * with Lora. The canonical token value per R2 designer reply + * 2026-05-19 is `"Lora", "Iowan Old Style", "Times New Roman", serif`. + * Creates a transient probe div, applies `.mks-display-1` (which sets + * font-family: var(--mks-font-display)), reads getComputedStyle, and + * asserts the resolved font-family stack starts with 'Lora' or + * '"Lora"' (browsers normalize quoting differently — both forms + * acceptable). */ +async function assertA21(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A21 — --mks-font-display resolves to Lora stack (R2 designer reply 2026-05-19)', + checks: [], + diagnostics: [], + }; + + let probe: HTMLDivElement | null = null; + try { + diag(result, "Step 1: create transient probe div with class='mks-display-1'"); + probe = document.createElement('div'); + probe.className = 'mks-display-1'; + probe.textContent = 'Probe'; + // Hide off-screen so the visual harness page doesn't shift layout. + probe.style.position = 'absolute'; + probe.style.left = '-9999px'; + probe.style.top = '-9999px'; + document.body.appendChild(probe); + + // Force a layout so CSS resolves. + void probe.offsetHeight; + + const computed = window.getComputedStyle(probe).fontFamily; + diag(result, `Step 1 result: getComputedStyle(probe).fontFamily=${JSON.stringify(computed)}`); + + // Accept both quoted ('Lora') and unquoted (Lora) leading forms. + // Chrome typically returns the family list with each member quoted + // if it contains a space or non-identifier; Lora is identifier-safe + // so it MAY be unquoted in the resolved stack. Belt-and-suspenders. + const startsWithLora = /^("Lora"|Lora)/.test(computed); + result.checks.push({ + name: "A21.1: getComputedStyle(.mks-display-1).fontFamily starts with Lora (no fallback chain hit)", + expected: "starts with '\"Lora\"' OR 'Lora'", + actual: computed, + passed: startsWithLora, + }); + + // Also confirm Newsreader is absent — would indicate a stale + // unmigrated tokens.css. + const newsreaderAbsent = !/Newsreader/i.test(computed); + result.checks.push({ + name: 'A21.2: resolved fontFamily does NOT contain Newsreader (R2 substitution complete)', + expected: 'no Newsreader', + actual: computed, + passed: newsreaderAbsent, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } finally { + if (probe !== null && probe.parentNode !== null) { + probe.parentNode.removeChild(probe); + } + } + + return result; +} + +/** A22 — welcome page tokens.css adoption. CONDITIONAL on Plan 01-10 + * having landed. If src/welcome/welcome.html is reachable, fetches it + * + the linked welcome.css and asserts substantive var(--mks-*) usage + * (regex /var\(--mks-[a-z-]+\)/g match count >= 3). If welcome.html + * returns HTTP 404, A22 PASSES with a 'Plan 01-10 not landed; skipped' + * diagnostic — mirrors the assertA12 ffprobe skip-gate pattern. */ +const A22_MIN_TOKEN_USES = 3; + +async function assertA22(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A22 — welcome page adopts canonical tokens.css (Plan 01-10 conditional; skip-gate on 404 OR fetch-failed)', + checks: [], + diagnostics: [], + }; + + try { + const welcomeUrl = chrome.runtime.getURL('src/welcome/welcome.html'); + diag(result, `Step 1: HEAD-equivalent fetch ${welcomeUrl} (skip-gate probe)`); + + // Chrome extensions throw a TypeError 'Failed to fetch' at the + // network layer when the requested path is not in + // web_accessible_resources OR the file is genuinely absent. Both + // failure modes mean Plan 01-10 has not landed — skip-gate + // accordingly. (A regular HTTP 404 also flows here through the + // try-pathway in case future Chrome versions normalize the + // behavior.) + let probe: Response; + try { + probe = await fetch(welcomeUrl); + } catch (fetchErr) { + const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr); + result.checks.push({ + name: "A22.SKIPPED: welcome.html fetch threw — Plan 01-10 not landed; A22 passes informationally", + expected: 'reachable OR network/404 (both mean 01-10 not landed)', + actual: `fetch threw: ${msg}`, + passed: true, + }); + result.passed = true; + diag(result, `A22 SKIPPED — Plan 01-10 not landed (fetch threw: ${msg})`); + return result; + } + diag(result, `Step 1 result: HTTP ${probe.status}`); + + if (probe.status === 404) { + // Skip-gate: Plan 01-10 has not yet landed; A22 PASSES with + // diagnostic per Plan 01-12 Wave 6 §interfaces note. + result.checks.push({ + name: "A22.SKIPPED: welcome.html returned 404 — Plan 01-10 not landed; A22 passes informationally", + expected: 'src/welcome/welcome.html reachable OR 404', + actual: 'HTTP 404', + passed: true, + }); + result.passed = true; + diag(result, 'A22 SKIPPED — Plan 01-10 not landed (welcome.html absent)'); + return result; + } + if (!probe.ok) { + throw new Error(`welcome.html returned unexpected HTTP ${probe.status} ${probe.statusText}`); + } + + diag(result, 'Step 2: read welcome.html body + extract targets'); + const html = await probe.text(); + const linkMatches = Array.from(html.matchAll(/]+rel=["']?stylesheet["']?[^>]+href=["']([^"']+)["']/gi)); + diag(result, `Step 2 result: ${linkMatches.length} stylesheet link(s) found`); + + if (linkMatches.length === 0) { + throw new Error('No found in welcome.html'); + } + + // Fetch each linked stylesheet + count var(--mks-*) usages across all of them. + let totalTokenUses = 0; + let hasTokensImport = false; + for (const match of linkMatches) { + const href = match[1]; + const resolved = new URL(href, welcomeUrl).href; + diag(result, `Step 3: fetch ${resolved}`); + const cssResp = await fetch(resolved); + if (!cssResp.ok) { + diag(result, `(skip ${href} — HTTP ${cssResp.status})`); + continue; + } + const css = await cssResp.text(); + const tokenMatches = css.match(/var\(--mks-[a-z-]+\)/g) ?? []; + totalTokenUses += tokenMatches.length; + if (/tokens\.css/.test(css)) hasTokensImport = true; + diag(result, `Step 3 result: ${href} contains ${tokenMatches.length} var(--mks-*) usages; tokens.css ref=${hasTokensImport}`); + } + + result.checks.push({ + name: `A22.1: welcome page stylesheets contain >= ${A22_MIN_TOKEN_USES} var(--mks-*) usages OR @import tokens.css`, + expected: `>= ${A22_MIN_TOKEN_USES} var(--mks-*) usages OR tokens.css reference`, + actual: `${totalTokenUses} var(--mks-*) + tokens.css=${hasTokensImport}`, + passed: totalTokenUses >= A22_MIN_TOKEN_USES || hasTokensImport, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + /** * Read `chrome.runtime.getManifest().version`. Used by the host-side * orchestrator at startup to capture the expected version for A13's @@ -2009,6 +2416,13 @@ declare global { assertA12: () => Promise; assertA13: () => Promise; assertA14: () => Promise; + // Plan 01-12 Wave 6 — design-integration assertions + assertA18: () => Promise; + assertA19: () => Promise; + assertA20: () => Promise; + assertA21: () => Promise; + assertA22: () => Promise; + // Plan 01-14 — picker narrowing assertA23: () => Promise; getManifestVersion: () => Promise; }; @@ -2030,15 +2444,20 @@ window.__mokoshHarness = { assertA12, assertA13, assertA14, + assertA18, + assertA19, + assertA20, + assertA21, + assertA22, assertA23, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA14, assertA23, getManifestVersion} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA14, assertA18..A22, assertA23, getManifestVersion} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-14: A23 + getManifestVersion)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + getManifestVersion)'); export {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index d4f15e5..1343d96 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -77,6 +77,13 @@ import { driveA12, driveA13, driveA14, + // Plan 01-12 Wave 6 — design integration assertions + driveA18, + driveA19, + driveA20, + driveA21, + driveA22, + // Plan 01-14 — picker-narrowing constraint driveA23, getManifestVersion, } from './lib/harness-page-driver'; @@ -324,6 +331,21 @@ async function main(): Promise { // notification ids state; no new SAVE dispatch — A13's already // exercised the SAVE path. Recording stays stopped after A14. { name: 'A14', drive: driveA14 }, + // Plan 01-12 Wave 6 — design integration assertions (read-only; + // independent of A14). Chained here so they execute regardless of + // the recording state machine; they only inspect static brand / + // i18n / token / icon surfaces. + // A18 — Lora WOFF2 reachability + size floor + // A19 — icons NOT the Bug A placeholders + // A20 — manifest:name resolves via chrome i18n + // A21 — --mks-font-display resolves to Lora + // A22 — welcome page tokens.css adoption (CONDITIONAL on 01-10 + // landing; auto-PASSes with skip-diagnostic on 404) + { name: 'A18', drive: driveA18 }, + { name: 'A19', drive: driveA19 }, + { name: 'A20', drive: driveA20 }, + { name: 'A21', drive: driveA21 }, + { name: 'A22', drive: driveA22 }, // Plan 01-14 A23: read-only inspection of the last getDisplayMedia // constraints object captured by A2's setupFreshRecording. Verifies // the production call at src/offscreen/recorder.ts:270 passes diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 6876fae..cdd5d4b 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -993,6 +993,105 @@ export async function driveA14(page: Page): Promise { }) as AssertionRecord; } +/* ─── Plan 01-12 Wave 6 — driveA18..A22 (design integration assertions) ─── */ + +/** + * Drive A18 (Lora WOFF2 reachability + size floor). Standard + * page.evaluate wrapper — page side walks document.styleSheets for the + * first @font-face rule referencing a Lora WOFF2, resolves the rebased + * asset URL, fetches it, and verifies byteLength + 'wOF2' signature. + * + * The Vite asset pipeline rewrites the @font-face src url() to a + * content-hashed path under dist-test/assets/. Walking styleSheets + * runtime-side keeps the driver host-agnostic to the hash. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with up to 4 checks (rule found, + * fetch status, byteLength floor, WOFF2 signature). + */ +export async function driveA18(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA18(); + return r; + }) as AssertionRecord; +} + +/** + * Drive A19 (icons are NOT Bug A placeholders). Standard page.evaluate + * wrapper — page side fetches icon128.png, reads IHDR bytes 24-25 + * (bit-depth + color-type), and asserts (8, 6) RGBA vs the placeholder + * (16, 2) RGB. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 2 checks (bit-depth + color-type). + */ +export async function driveA19(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA19(); + return r; + }) as AssertionRecord; +} + +/** + * Drive A20 (manifest:name resolves via chrome i18n). Standard + * page.evaluate wrapper — page side reads chrome.runtime.getManifest() + * and asserts manifest.name resolved to the EN or RU extName value + * (no __MSG_ placeholder leak). + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 2 checks (resolved value, no __MSG_). + */ +export async function driveA20(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA20(); + return r; + }) as AssertionRecord; +} + +/** + * Drive A21 (--mks-font-display resolves to Lora). Standard + * page.evaluate wrapper — page side creates a transient .mks-display-1 + * probe div, reads getComputedStyle.fontFamily, and asserts the stack + * starts with 'Lora' (no Newsreader leak). + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 2 checks (Lora prefix, no Newsreader). + */ +export async function driveA21(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA21(); + return r; + }) as AssertionRecord; +} + +/** + * Drive A22 (welcome page tokens.css adoption — CONDITIONAL on Plan + * 01-10 having landed). Standard page.evaluate wrapper — page side + * fetches welcome.html; on HTTP 404 PASSES with a 'Plan 01-10 not + * landed; skipped' diagnostic. On HTTP 200, extracts stylesheet + * s + asserts substantive var(--mks-*) usage OR a tokens.css + * reference in the linked CSS files. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 1 check (skip or token usage). + */ +export async function driveA22(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA22(); + return r; + }) as AssertionRecord; +} + /* ─── Plan 01-14 — driveA23 (monitorTypeSurfaces picker-narrowing) ─── */ /**