Milestone v1 (v2.0.0): Mokosh — Session Capture #1
194
scripts/04-06-welcome-hero-screenshots.mjs
Normal file
194
scripts/04-06-welcome-hero-screenshots.mjs
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user