From 7e991ed7ab7044e7dd323771aeb8a3a1ac1da8f7 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Mon, 13 Jul 2026 02:44:40 +0200 Subject: [PATCH 1/2] feat(tooling): gate canvas screenshots on render verdicts --- .../drift.md | 17 +++ .llm/tools/canvas-shots/args.ts | 118 +++++++++++++++ .llm/tools/canvas-shots/browser.ts | 58 ++++++++ .llm/tools/canvas-shots/canvas_shots_test.ts | 129 +++++++++++++++++ .llm/tools/canvas-shots/mod.ts | 136 ++++++++++++++++++ .llm/tools/canvas-shots/naming.ts | 14 ++ .llm/tools/canvas-shots/redact.ts | 7 + .llm/tools/canvas-shots/theme.ts | 9 ++ .llm/tools/canvas-shots/verdict.ts | 27 ++++ deno.json | 5 +- 10 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 .llm/tools/canvas-shots/args.ts create mode 100644 .llm/tools/canvas-shots/browser.ts create mode 100644 .llm/tools/canvas-shots/canvas_shots_test.ts create mode 100644 .llm/tools/canvas-shots/mod.ts create mode 100644 .llm/tools/canvas-shots/naming.ts create mode 100644 .llm/tools/canvas-shots/redact.ts create mode 100644 .llm/tools/canvas-shots/theme.ts create mode 100644 .llm/tools/canvas-shots/verdict.ts diff --git a/.llm/runs/feat-dashboard-design-prototype--design/drift.md b/.llm/runs/feat-dashboard-design-prototype--design/drift.md index e30784c82..e07016857 100644 --- a/.llm/runs/feat-dashboard-design-prototype--design/drift.md +++ b/.llm/runs/feat-dashboard-design-prototype--design/drift.md @@ -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). diff --git a/.llm/tools/canvas-shots/args.ts b/.llm/tools/canvas-shots/args.ts new file mode 100644 index 000000000..43b9bc374 --- /dev/null +++ b/.llm/tools/canvas-shots/args.ts @@ -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 }; +} diff --git a/.llm/tools/canvas-shots/browser.ts b/.llm/tools/canvas-shots/browser.ts new file mode 100644 index 000000000..2cbc89937 --- /dev/null +++ b/.llm/tools/canvas-shots/browser.ts @@ -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 { + 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}`, + ); +} diff --git a/.llm/tools/canvas-shots/canvas_shots_test.ts b/.llm/tools/canvas-shots/canvas_shots_test.ts new file mode 100644 index 000000000..5d0804443 --- /dev/null +++ b/.llm/tools/canvas-shots/canvas_shots_test.ts @@ -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 '); + assertEquals( + redactServeUrl(`404 ${serveUrl}assets/app.js`, serveUrl), + '404 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('
{{ b }}
'), ['{{ 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'); +}); diff --git a/.llm/tools/canvas-shots/mod.ts b/.llm/tools/canvas-shots/mod.ts new file mode 100644 index 000000000..fc8202d28 --- /dev/null +++ b/.llm/tools/canvas-shots/mod.ts @@ -0,0 +1,136 @@ +import { chromium, type Page } from 'playwright'; +import { join } from '@std/path'; +import { type CanvasShotsOptions, parseArgs, type Theme } from './args.ts'; +import { browserLaunchError, defaultBrowserCacheDir, resolveChromiumPath } from './browser.ts'; +import { routeUrl, shotFilename } from './naming.ts'; +import { redactServeUrl } from './redact.ts'; +import { themeApplyScript } from './theme.ts'; +import { defectExitCode, isDefective, type ShotResult, unresolvedHoles } from './verdict.ts'; + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} + +async function inspectPage(page: Page): Promise<{ windowNSOne: boolean; holes: string[] }> { + const values = await page.evaluate(() => ({ + windowNSOne: typeof (window as Window & { NSOne?: unknown }).NSOne !== 'undefined', + html: document.documentElement.outerHTML, + })); + return { windowNSOne: values.windowNSOne, holes: unresolvedHoles(values.html) }; +} + +async function captureShot( + browser: Awaited>, + options: CanvasShotsOptions, + route: string, + theme: Theme, +): Promise { + const context = await browser.newContext({ + colorScheme: theme, + viewport: options.viewport, + deviceScaleFactor: options.scale, + }); + await context.addInitScript(themeApplyScript(theme)); + const page = await context.newPage(); + const consoleErrors: string[] = []; + const failedRequests: string[] = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(redactServeUrl(message.text(), options.serveUrl)); + } + }); + page.on('response', (response) => { + if (response.status() === 404 && !response.request().isNavigationRequest()) { + failedRequests.push(redactServeUrl(response.url(), options.serveUrl)); + } + }); + + try { + await page.goto(routeUrl(options.serveUrl, route), { waitUntil: 'load' }); + await page.waitForTimeout(options.settleMs); + const inspection = await inspectPage(page); + const file = shotFilename(route, theme); + await page.screenshot({ path: join(options.outDir, file), fullPage: true }); + return { + route, + theme, + file, + windowNSOne: inspection.windowNSOne, + consoleErrors: unique(consoleErrors), + failedRequests: unique(failedRequests), + unresolvedHoles: inspection.holes, + }; + } finally { + await context.close(); + } +} + +function printResults( + results: ShotResult[], + format: 'json' | 'pretty', + allowDefects: boolean, +): void { + const defective = results.filter(isDefective).length; + if (format === 'json') { + console.log(JSON.stringify({ ok: defective === 0, defective, results })); + return; + } + for (const result of results) { + const state = isDefective(result) ? 'DEFECT' : 'PASS'; + console.log( + `${state} ${result.route || ''} ${result.theme} -> ${result.file} ` + + `(NSOne=${result.windowNSOne}, console=${result.consoleErrors.length}, ` + + `404=${result.failedRequests.length}, holes=${result.unresolvedHoles.length})`, + ); + } + console.log( + `${results.length} shot(s), ${defective} defective${allowDefects ? ' (allowed)' : ''}`, + ); +} + +/** Runs the canvas screenshot matrix and returns its defect-sensitive exit code. */ +export async function run(args: string[]): Promise { + let options: CanvasShotsOptions; + try { + options = parseArgs(args); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } + + await Deno.mkdir(options.outDir, { recursive: true }); + const executablePath = await resolveChromiumPath({ + envPath: Deno.env.get('CANVAS_SHOTS_CHROMIUM'), + cacheDir: defaultBrowserCacheDir(Deno.env.get('HOME')), + }); + + let browser: Awaited>; + try { + browser = await chromium.launch(executablePath ? { executablePath } : {}); + } catch (error) { + console.error(browserLaunchError(error, executablePath).message); + return 2; + } + + const results: ShotResult[] = []; + try { + for (const route of options.routes) { + for (const theme of options.themes) { + results.push(await captureShot(browser, options, route, theme)); + } + } + } catch (error) { + console.error( + redactServeUrl(error instanceof Error ? error.message : String(error), options.serveUrl), + ); + return 2; + } finally { + await browser.close(); + } + + printResults(results, options.format, options.allowDefects); + return defectExitCode(results, options.allowDefects); +} + +if (import.meta.main) Deno.exit(await run(Deno.args)); diff --git a/.llm/tools/canvas-shots/naming.ts b/.llm/tools/canvas-shots/naming.ts new file mode 100644 index 000000000..726425301 --- /dev/null +++ b/.llm/tools/canvas-shots/naming.ts @@ -0,0 +1,14 @@ +import type { Theme } from './args.ts'; + +/** Converts a hash route and theme to a deterministic PNG filename. */ +export function shotFilename(route: string, theme: Theme): string { + const slug = route.trim().replace(/^#+/, '').replace(/^\/+|\/+$/g, '') + .replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'home'; + return `${slug}--${theme}.png`; +} + +/** Appends a hash route without retaining an existing hash fragment. */ +export function routeUrl(serveUrl: string, route: string): string { + const base = serveUrl.replace(/#.*$/, ''); + return `${base}#${route.replace(/^#/, '')}`; +} diff --git a/.llm/tools/canvas-shots/redact.ts b/.llm/tools/canvas-shots/redact.ts new file mode 100644 index 000000000..bed8f0706 --- /dev/null +++ b/.llm/tools/canvas-shots/redact.ts @@ -0,0 +1,7 @@ +/** Redacts the project-scoped canvas URL from diagnostic text. */ +export function redactServeUrl(value: string, serveUrl: string): string { + const variants = [serveUrl, serveUrl.replace(/\/$/, '')].filter((item, index, all) => + item.length > 0 && all.indexOf(item) === index + ); + return variants.reduce((text, secret) => text.replaceAll(secret, ''), value); +} diff --git a/.llm/tools/canvas-shots/theme.ts b/.llm/tools/canvas-shots/theme.ts new file mode 100644 index 000000000..12edf801c --- /dev/null +++ b/.llm/tools/canvas-shots/theme.ts @@ -0,0 +1,9 @@ +import type { Theme } from './args.ts'; + +export const LIGHT_THEME_SCRIPT = "document.documentElement.removeAttribute('data-theme');"; +export const DARK_THEME_SCRIPT = "document.documentElement.setAttribute('data-theme','dark');"; + +/** Returns the NS One document-theme initialization script. */ +export function themeApplyScript(theme: Theme): string { + return theme === 'dark' ? DARK_THEME_SCRIPT : LIGHT_THEME_SCRIPT; +} diff --git a/.llm/tools/canvas-shots/verdict.ts b/.llm/tools/canvas-shots/verdict.ts new file mode 100644 index 000000000..4f9a5a48b --- /dev/null +++ b/.llm/tools/canvas-shots/verdict.ts @@ -0,0 +1,27 @@ +import type { Theme } from './args.ts'; + +export interface ShotResult { + route: string; + theme: Theme; + file: string; + windowNSOne: boolean; + consoleErrors: string[]; + failedRequests: string[]; + unresolvedHoles: string[]; +} + +/** Reports whether a screenshot result violates the canvas render contract. */ +export function isDefective(result: ShotResult): boolean { + return !result.windowNSOne || result.consoleErrors.length > 0 || + result.failedRequests.length > 0 || result.unresolvedHoles.length > 0; +} + +/** Classifies the process exit code for a complete screenshot run. */ +export function defectExitCode(results: ShotResult[], allowDefects: boolean): number { + return !allowDefects && results.some(isDefective) ? 1 : 0; +} + +/** Finds every unfilled design-template hole still present in rendered markup. */ +export function unresolvedHoles(html: string): string[] { + return html.match(/{{[\s\S]*?}}/g) ?? []; +} diff --git a/deno.json b/deno.json index c35b1ecf1..50b17059e 100644 --- a/deno.json +++ b/deno.json @@ -67,6 +67,7 @@ "agentic:codex-resume": "deno run --no-lock --allow-read --allow-run .llm/tools/agentic/codex/codex-resume.ts", "agentic:codex-status": "deno run --no-lock --allow-run .llm/tools/agentic/codex/codex-status.ts", "agentic:codex-watch": "deno run --no-lock --allow-read --allow-run --allow-env .llm/tools/agentic/codex/codex-watch.ts", + "canvas:shots": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net --allow-sys .llm/tools/canvas-shots/mod.ts", "agentic:launch-codex-slice": "deno run --no-lock --allow-read --allow-write --allow-run --allow-env .llm/tools/agentic/codex/launch-codex-slice.ts", "agentic:dispatch-openhands": "deno run --no-lock --allow-read --allow-env --allow-net .llm/tools/agentic/openhands/dispatch-openhands.ts", "agentic:openhands-status": "deno run --no-lock --allow-read --allow-env --allow-net .llm/tools/agentic/openhands/openhands-status.ts", @@ -118,7 +119,8 @@ }, "imports": { "@std/assert": "jsr:@std/assert@1", - "@std/path": "jsr:@std/path@1" + "@std/path": "jsr:@std/path@1", + "playwright": "catalog:" }, "compilerOptions": { "strict": true, @@ -200,6 +202,7 @@ "jose": "^6.2.3", "mysql2": "^3.22.5", "pg": "^8.21.0", + "playwright": "1.61.1", "preact": "^10.29.2", "preact-render-to-string": "^6.7.0", "tailwindcss": "^4.3.1", From 1b0efb7faa40261b58a61c5a3c15b3d84dd7d5df Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Mon, 13 Jul 2026 03:58:01 +0200 Subject: [PATCH 2/2] chore(canvas-shots): lock the playwright dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas-shots tool landed without its lock entry, so its `npm:playwright` import would not resolve for anyone else checking out the branch. Verified the delta is scoped: playwright + playwright-core + fsevents added; the three removed lines are a re-organization within a member's dependency list, not a drop — `jsr:@std/path@1` is still present (deno.lock:76 → 1.1.5) and `deno check` on the tool passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HTiQfrNFCVhjQFqbLKm5xo --- deno.lock | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/deno.lock b/deno.lock index fdb2398e1..aa6ed4b36 100644 --- a/deno.lock +++ b/deno.lock @@ -156,6 +156,8 @@ "npm:mysql2@^3.22.5": "3.22.5_@types+node@25.9.3", "npm:pg@^8.21.0": "8.21.0", "npm:pkijs@^3.2.5": "3.4.0", + "npm:playwright@*": "1.61.1", + "npm:playwright@1.61.1": "1.61.1", "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.2", "npm:preact-render-to-string@^6.7.0": "6.7.0_preact@10.29.2", "npm:preact@^10.28.2": "10.29.2", @@ -2378,6 +2380,11 @@ "fresh@2.0.0": { "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" }, + "fsevents@2.3.2": { + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "os": ["darwin"], + "scripts": true + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -3013,6 +3020,20 @@ "tslib" ] }, + "playwright-core@1.61.1": { + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "bin": true + }, + "playwright@1.61.1": { + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dependencies": [ + "playwright-core" + ], + "optionalDependencies": [ + "fsevents@2.3.2" + ], + "bin": true + }, "possible-typed-array-names@1.1.0": { "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" }, @@ -3177,7 +3198,7 @@ "@rollup/rollup-win32-ia32-msvc", "@rollup/rollup-win32-x64-gnu", "@rollup/rollup-win32-x64-msvc", - "fsevents" + "fsevents@2.3.3" ], "bin": true }, @@ -3505,7 +3526,7 @@ "tinyglobby" ], "optionalDependencies": [ - "fsevents" + "fsevents@2.3.3" ], "bin": true }, @@ -3571,7 +3592,8 @@ "workspace": { "dependencies": [ "jsr:@std/assert@1", - "jsr:@std/path@1" + "jsr:@std/path@1", + "npm:playwright@1.61.1" ], "members": { "packages/ai": {