Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .llm/runs/feat-dashboard-design-prototype--design/drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,20 @@ documentation.
styles; ModelSelector OPEN staged; `proto.css` waterfall tick container-query + rail StatsGrid
column cap. All re-shot locally and verified before upload.
- **Severity:** significant (false "fixed" state shipped in 3.2; render-gate process gap now closed).

## D7 — 2026-07-13 — canvas-shots harness slice activation and test permission correction (minor)

- **What:** The reusable screenshot gate moved from the scratch render loop described in D6 to the
dedicated `.llm/tools/canvas-shots/` slice. Its separate Claude-family PLAN-EVAL first launch
stalled without output and was terminated; a second isolated read-only PLAN-EVAL completed
`PASS` before implementation. The owner-scoped run forbade new plan-eval artifacts, so the
verdict remained session evidence and this append is the only run-dir change.
- **Validation drift:** The brief specified bare `deno test .llm/tools/canvas-shots/` while also
requiring the browser resolver to be tested against a temporary directory. Deno denies temp-dir
creation without parent `--allow-read --allow-write`; per-test permissions cannot escalate the
parent test process. The executable gate is therefore `deno test --allow-read --allow-write
.llm/tools/canvas-shots/` (9 tests), preserving the required real temp-directory coverage.
- **Dependency decision:** Playwright is pinned through the root npm catalog/import alias; `deno
task deps:latest --filter playwright` reported `0 behind / 1 total`. Pre-existing `deno.lock`
churn was preserved as unrelated and excluded from the slice commit.
- **Severity:** minor (process/command correction; no product or canvas contract change).
118 changes: 118 additions & 0 deletions .llm/tools/canvas-shots/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
export const THEMES = ['light', 'dark'] as const;

export type Theme = (typeof THEMES)[number];

export interface CanvasShotsOptions {
serveUrl: string;
outDir: string;
routes: string[];
themes: Theme[];
viewport: { width: number; height: number };
scale: number;
settleMs: number;
format: 'json' | 'pretty';
allowDefects: boolean;
}

function takeValue(args: string[], index: number, flag: string): string {
const value = args[index + 1];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
}

function positiveNumber(value: string, flag: string, integer = false): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0 || (integer && !Number.isInteger(parsed))) {
throw new Error(`${flag} must be a positive ${integer ? 'integer' : 'number'}`);
}
return parsed;
}

function parseViewport(value: string): { width: number; height: number } {
const match = /^(\d+)x(\d+)$/.exec(value);
if (!match) throw new Error('--viewport must have the form WIDTHxHEIGHT');
return {
width: positiveNumber(match[1], '--viewport width', true),
height: positiveNumber(match[2], '--viewport height', true),
};
}

function parseRoutes(value: string): string[] {
return value.split(',').map((route) => route.trim());
}

function parseThemes(value: string): Theme[] {
const values = value.split(',').map((theme) => theme.trim());
if (values.length === 0 || values.some((theme) => !THEMES.includes(theme as Theme))) {
throw new Error('--themes accepts only light,dark');
}
return values as Theme[];
}

/** Parses the canvas screenshot command-line contract. */
export function parseArgs(args: string[]): CanvasShotsOptions {
let serveUrl: string | undefined;
let outDir: string | undefined;
let routes = [''];
let themes: Theme[] = [...THEMES];
let viewport = { width: 1440, height: 900 };
let scale = 2;
let settleMs = 2500;
let format: 'json' | 'pretty' = 'pretty';
let formatFlag: '--json' | '--pretty' | undefined;
let allowDefects = false;

for (let index = 0; index < args.length; index++) {
const flag = args[index];
switch (flag) {
case '--serve-url':
serveUrl = takeValue(args, index++, flag);
break;
case '--out':
outDir = takeValue(args, index++, flag);
break;
case '--routes':
routes = parseRoutes(takeValue(args, index++, flag));
break;
case '--themes':
themes = parseThemes(takeValue(args, index++, flag));
break;
case '--viewport':
viewport = parseViewport(takeValue(args, index++, flag));
break;
case '--scale':
scale = positiveNumber(takeValue(args, index++, flag), flag);
break;
case '--settle-ms':
settleMs = positiveNumber(takeValue(args, index++, flag), flag, true);
break;
case '--json':
if (formatFlag) throw new Error('--json and --pretty may be passed only once');
formatFlag = flag;
format = 'json';
break;
case '--pretty':
if (formatFlag) throw new Error('--json and --pretty may be passed only once');
formatFlag = flag;
format = 'pretty';
break;
case '--allow-defects':
allowDefects = true;
break;
default:
throw new Error(`unknown option: ${flag}`);
}
}

if (!serveUrl) throw new Error('--serve-url is required');
if (!outDir) throw new Error('--out is required');
try {
new URL(serveUrl);
} catch {
throw new Error('--serve-url must be an absolute URL');
}

return { serveUrl, outDir, routes, themes, viewport, scale, settleMs, format, allowDefects };
}
58 changes: 58 additions & 0 deletions .llm/tools/canvas-shots/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { join } from '@std/path';

export interface BrowserPathOptions {
envPath?: string;
cacheDir?: string;
}

function revision(name: string): number {
const match = /^chromium-(\d+)$/.exec(name);
return match ? Number(match[1]) : -1;
}

/** Resolves an explicit Chromium executable without downloading a browser. */
export async function resolveChromiumPath(
options: BrowserPathOptions,
): Promise<string | undefined> {
if (options.envPath) return options.envPath;
if (!options.cacheDir) return undefined;

const candidates: Array<{ revision: number; path: string }> = [];
try {
for await (const entry of Deno.readDir(options.cacheDir)) {
if (!entry.isDirectory) continue;
const parsedRevision = revision(entry.name);
if (parsedRevision < 0) continue;
const path = join(options.cacheDir, entry.name, 'chrome-linux64', 'chrome');
try {
if ((await Deno.stat(path)).isFile) candidates.push({ revision: parsedRevision, path });
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}

candidates.sort((left, right) => right.revision - left.revision);
return candidates[0]?.path;
}

/** Returns the default Playwright browser cache directory for the current user. */
export function defaultBrowserCacheDir(home: string | undefined): string | undefined {
return home ? join(home, '.cache', 'ms-playwright') : undefined;
}

/** Adds browser-revision recovery guidance without leaking navigation URLs. */
export function browserLaunchError(error: unknown, explicitPath: string | undefined): Error {
const cause = error instanceof Error ? error.message : String(error);
const source = explicitPath
? `Chromium executable ${explicitPath} could not be launched.`
: 'Playwright could not resolve or launch its expected Chromium build.';
return new Error(
`${source} The installed Playwright package and cached browser revision may not match. ` +
'Set CANVAS_SHOTS_CHROMIUM to a compatible chrome executable, or explicitly install the ' +
`pinned browser build with "deno run -A npm:playwright install chromium". ` +
`No browser was downloaded automatically. Cause: ${cause}`,
);
}
129 changes: 129 additions & 0 deletions .llm/tools/canvas-shots/canvas_shots_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { assertEquals, assertStringIncludes, assertThrows } from '@std/assert';
import { join } from '@std/path';
import { parseArgs } from './args.ts';
import { browserLaunchError, resolveChromiumPath } from './browser.ts';
import { routeUrl, shotFilename } from './naming.ts';
import { redactServeUrl } from './redact.ts';
import { DARK_THEME_SCRIPT, LIGHT_THEME_SCRIPT, themeApplyScript } from './theme.ts';
import { defectExitCode, type ShotResult, unresolvedHoles } from './verdict.ts';

Deno.test('parseArgs applies defaults and parses the full contract', () => {
assertEquals(
parseArgs([
'--serve-url',
'https://example.invalid/secret',
'--out',
'shots',
'--routes',
',catalog/item',
'--themes',
'dark',
'--viewport',
'800x600',
'--scale',
'1.5',
'--settle-ms',
'50',
'--json',
'--allow-defects',
]),
{
serveUrl: 'https://example.invalid/secret',
outDir: 'shots',
routes: ['', 'catalog/item'],
themes: ['dark'],
viewport: { width: 800, height: 600 },
scale: 1.5,
settleMs: 50,
format: 'json',
allowDefects: true,
},
);
});

Deno.test('parseArgs rejects missing required and malformed values', () => {
assertThrows(() => parseArgs(['--out', 'shots']), Error, '--serve-url is required');
assertThrows(
() => parseArgs(['--serve-url', 'https://example.invalid', '--out', 'x', '--themes', 'blue']),
Error,
'--themes accepts only light,dark',
);
});

Deno.test('route filename slugging includes home and theme', () => {
assertEquals(shotFilename('', 'light'), 'home--light.png');
assertEquals(shotFilename('/API Explorer/:id', 'dark'), 'api-explorer-id--dark.png');
});

Deno.test('hash routes retain the secret serve URL only for navigation', () => {
const serveUrl = 'https://project.claudeusercontent.com/scoped-token#old';
assertEquals(
routeUrl(serveUrl, '#catalog/item'),
'https://project.claudeusercontent.com/scoped-token#catalog/item',
);
assertEquals(routeUrl(serveUrl, ''), 'https://project.claudeusercontent.com/scoped-token#');
});

Deno.test('serve URL redaction covers the base and descendant diagnostics', () => {
const serveUrl = 'https://project.claudeusercontent.com/scoped-token/';
assertEquals(redactServeUrl(`failed ${serveUrl}`, serveUrl), 'failed <serve-url>');
assertEquals(
redactServeUrl(`404 ${serveUrl}assets/app.js`, serveUrl),
'404 <serve-url>assets/app.js',
);
});

Deno.test('theme scripts encode NS One light-default and dark attribute semantics', () => {
assertEquals(themeApplyScript('light'), LIGHT_THEME_SCRIPT);
assertEquals(themeApplyScript('dark'), DARK_THEME_SCRIPT);
assertEquals(LIGHT_THEME_SCRIPT, "document.documentElement.removeAttribute('data-theme');");
assertEquals(DARK_THEME_SCRIPT, "document.documentElement.setAttribute('data-theme','dark');");
});

Deno.test('defect classifier controls exit behavior', () => {
const clean: ShotResult = {
route: '',
theme: 'light',
file: 'home--light.png',
windowNSOne: true,
consoleErrors: [],
failedRequests: [],
unresolvedHoles: [],
};
assertEquals(defectExitCode([clean], false), 0);
assertEquals(defectExitCode([{ ...clean, windowNSOne: false }], false), 1);
assertEquals(defectExitCode([{ ...clean, consoleErrors: ['boom'] }], false), 1);
assertEquals(defectExitCode([{ ...clean, failedRequests: ['missing.js'] }], false), 1);
assertEquals(defectExitCode([{ ...clean, unresolvedHoles: ['{{ value }}'] }], false), 1);
assertEquals(defectExitCode([{ ...clean, windowNSOne: false }], true), 0);
assertEquals(unresolvedHoles('<div title="{{ a }}">{{ b }}</div>'), ['{{ a }}', '{{ b }}']);
});

Deno.test({
name: 'browser resolver honors env then newest valid cached revision',
async fn() {
const root = await Deno.makeTempDir();
try {
const older = join(root, 'chromium-1228', 'chrome-linux64');
const newer = join(root, 'chromium-1232', 'chrome-linux64');
await Deno.mkdir(older, { recursive: true });
await Deno.mkdir(newer, { recursive: true });
await Deno.writeTextFile(join(older, 'chrome'), '');
await Deno.writeTextFile(join(newer, 'chrome'), '');
assertEquals(
await resolveChromiumPath({ envPath: '/explicit/chrome', cacheDir: root }),
'/explicit/chrome',
);
assertEquals(await resolveChromiumPath({ cacheDir: root }), join(newer, 'chrome'));
assertEquals(await resolveChromiumPath({ cacheDir: join(root, 'missing') }), undefined);
} finally {
await Deno.remove(root, { recursive: true });
}
},
});

Deno.test('browser launch failure is actionable', () => {
const message = browserLaunchError(new Error('revision 1228 missing'), undefined).message;
assertStringIncludes(message, 'CANVAS_SHOTS_CHROMIUM');
assertStringIncludes(message, 'No browser was downloaded automatically');
});
Loading