Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit d66cbf6900 - Show all commits

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
// scripts/04-06-welcome-hero-screenshots.mjs — Plan 04-06 Task 4
// operator-empirical screenshot artifact.
//
// Per the orchestrator checkpoint protocol + saved memory
// `feedback-trust-harness-over-manual-uat`, the operator judges the
// dark-mode aesthetic from Puppeteer screenshots, NOT a manual Chrome
// session. This script:
// 1. Loads dist/ as an unpacked extension via puppeteer.launch
// enableExtensions.
// 2. Resolves the runtime extension ID by polling the targets list.
// 3. Opens chrome-extension://<id>/src/welcome/welcome.html, waits
// for the inline-<svg> selector (populateMark ran), then
// page.screenshot the `.welcome-hero` region — the LIGHT-surface
// regression-baseline shot.
// 4. Both emulateMediaFeatures([prefers-color-scheme: dark]) AND
// sets [data-theme="dark"] on the <html> element. Mokosh's
// src/shared/tokens.css cascade only fires on the explicit class
// (`.dark` / `[data-theme="dark"]` selector at line 234) — there
// is currently no `@media (prefers-color-scheme: dark)` block —
// so the attribute-set is the actual trigger. The media-feature
// emulation is forward-compatible with any future @media block.
// 5. Re-screenshot the `.welcome-hero` region — the DARK-surface
// aesthetic-judgment shot.
//
// Output paths (canonical per the 04-06-PLAN.md Task 4 contract):
// - /tmp/04-06-welcome-hero-light.png
// - /tmp/04-06-welcome-hero-dark.png
//
// References:
// - https://pptr.dev/api/puppeteer.page.emulatemediafeatures
// - https://pptr.dev/api/puppeteer.page.screenshot
// - tests/uat/lib/launch.ts (the canonical extension-loading pattern
// this script mirrors; A35 driver uses the same chrome-extension://
// welcome.html URL).
// - .planning/phases/04-harden-clean-up-optional/04-UI-SPEC.md
// §"Implementation amendment" (Option A currentColor cascade).
import puppeteer from 'puppeteer';
import { resolve as resolvePath } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const DIST_DIR = resolvePath(process.cwd(), 'dist');
const LIGHT_PNG = '/tmp/04-06-welcome-hero-light.png';
const DARK_PNG = '/tmp/04-06-welcome-hero-dark.png';
const EXTENSION_ID_TIMEOUT_MS = 10_000;
const WELCOME_PAGE_TIMEOUT_MS = 10_000;
/**
* Resolve the runtime extension ID via the canonical Puppeteer
* `browser.extensions()` Map (Puppeteer >= 22.x). Mirrors the
* resolveExtensionIdWithPolling helper at tests/uat/lib/launch.ts —
* kept inline here so this script is a single-file artifact.
*/
async function resolveExtensionId(browser) {
const deadline = Date.now() + EXTENSION_ID_TIMEOUT_MS;
while (Date.now() < deadline) {
const extensionsMap = await browser.extensions();
const entries = [...extensionsMap];
if (entries.length > 0) {
// entries is Array<[extensionId, manifest]>; take the first
// entry's key (we loaded exactly one extension via
// enableExtensions:[DIST_DIR] so there is no ambiguity).
return entries[0][0];
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Extension ID not resolvable within ${EXTENSION_ID_TIMEOUT_MS}ms — `
+ `is dist/ a valid unpacked extension?`,
);
}
async function captureWelcomeHero({ outputPath, dark, browser, extensionId }) {
const page = await browser.newPage();
try {
// emulateMediaFeatures is forward-compat with any future @media
// (prefers-color-scheme: dark) block in tokens.css; today's
// tokens.css fires the dark cascade via [data-theme="dark"] on
// <html>, which we set explicitly below before navigating.
if (dark) {
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'dark' },
]);
}
const welcomeUrl = `chrome-extension://${extensionId}/src/welcome/welcome.html`;
await page.goto(welcomeUrl, { waitUntil: 'domcontentloaded' });
if (dark) {
// Set [data-theme="dark"] on <html> so the
// .dark, [data-theme="dark"] { ... } block in
// src/shared/tokens.css lines 234-251 actually fires.
await page.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
}
await page.waitForSelector('.welcome-hero', {
timeout: WELCOME_PAGE_TIMEOUT_MS,
});
await page.waitForSelector('.welcome-hero__mark svg', {
timeout: WELCOME_PAGE_TIMEOUT_MS,
});
// Allow a brief settle so the dark token override / font-load /
// style flush before screenshot.
await new Promise((resolve) => setTimeout(resolve, 250));
// Read back the computed stroke + the hero element's bounding box
// in a single evaluate call so we minimize CDP round trips (some
// headless environments throttle Runtime.callFunctionOn on
// extension pages — observed empirically; switching from
// ElementHandle.screenshot() to page.screenshot({clip}) avoids
// a second elementHandle.evaluate which was timing out).
const probe = await page.evaluate(() => {
const heroEl = document.querySelector('.welcome-hero');
const svgEl = document.querySelector('.welcome-hero__mark svg');
if (heroEl === null) return null;
const r = heroEl.getBoundingClientRect();
return {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
computedStroke:
svgEl === null ? '<null>' : getComputedStyle(svgEl).stroke,
};
});
if (probe === null) {
throw new Error('.welcome-hero element not found post-settle');
}
await page.screenshot({
path: outputPath,
clip: {
x: Math.max(0, Math.floor(probe.x)),
y: Math.max(0, Math.floor(probe.y)),
width: Math.ceil(probe.width),
height: Math.ceil(probe.height),
},
});
return { outputPath, computedStroke: probe.computedStroke };
} finally {
await page.close();
}
}
async function main() {
const downloadsDir = mkdtempSync(join(tmpdir(), 'mokosh-04-06-screenshots-'));
const browser = await puppeteer.launch({
enableExtensions: [DIST_DIR],
headless: true,
pipe: true,
// Match the UAT harness protocolTimeout (PROTOCOL_TIMEOUT_MS at
// tests/uat/lib/launch.ts) — the default 30s is sometimes too
// tight for headless extension page navigation + style settle
// on slower machines.
protocolTimeout: 120_000,
args: ['--no-sandbox'],
});
try {
const extensionId = await resolveExtensionId(browser);
process.stdout.write(`Resolved extension ID: ${extensionId}\n`);
const light = await captureWelcomeHero({
outputPath: LIGHT_PNG,
dark: false,
browser,
extensionId,
});
process.stdout.write(
`LIGHT screenshot: ${light.outputPath} `
+ `(computed stroke: ${light.computedStroke})\n`,
);
const dark = await captureWelcomeHero({
outputPath: DARK_PNG,
dark: true,
browser,
extensionId,
});
process.stdout.write(
`DARK screenshot: ${dark.outputPath} `
+ `(computed stroke: ${dark.computedStroke})\n`,
);
} finally {
await browser.close();
// Inform the caller about the per-run temp directory (cleanup is
// OS-managed under /tmp — same lifecycle as the UAT harness).
process.stdout.write(`Temp dir (auto-cleaned by OS): ${downloadsDir}\n`);
}
}
main().catch((err) => {
process.stderr.write(`FATAL: ${err instanceof Error ? err.stack : String(err)}\n`);
process.exit(1);
});