Files
Paul BakausandGitHub 6496f49a1e Fix skill workflow regression coverage (#783)
Clarify launcher fallback and completed documentation handoffs; separate bounded protocol checkpoints from opt-in browser-backed completion diagnostics. Correct fixture containment, target syntax, and artifact assertions. AI assistance: Codex, under maintainer direction.
2026-09-08 08:41:54 -07:00

132 lines
6.9 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import { sourceHash as hashSources } from './source-hash.mjs';
import { chromium } from 'playwright';
import { tool } from 'ai';
import { z } from 'zod';
const VIEWPORTS = { desktop: { width: 1440, height: 1000 }, mobile: { width: 390, height: 844 } };
const TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
const PNG = Buffer.from('89504e470d0a1a0a', 'hex');
function resolveFile(root, name) {
if (path.isAbsolute(name)) throw new Error('Use a workspace-relative path');
const file = path.resolve(root, name);
const rel = path.relative(root, file);
if (rel === '..' || rel.startsWith(`..${path.sep}`)) throw new Error('Path escapes workspace');
const real = fs.realpathSync(file);
const realRel = path.relative(fs.realpathSync(root), real);
if (realRel === '..' || realRel.startsWith(`..${path.sep}`)) throw new Error('Path escapes workspace through a symlink');
return real;
}
export function imageOutput({ output }) {
const { image, ...metadata } = output;
return { type: 'content', value: [
{ type: 'text', text: JSON.stringify(metadata) },
{ type: 'file', mediaType: 'image/png', data: { type: 'data', data: image } },
] };
}
/** Preflight before any billed call; no runtime installs or browser discovery. */
export async function prepareBrowser(root) {
let browser;
try {
browser = await chromium.launch({ headless: true, timeout: 15000 });
} catch (error) {
throw new Error('Workflow browser preflight failed. Run `bunx playwright install chromium` before billed tests.', { cause: error });
}
const blockedRequests = [];
const server = http.createServer((req, res) => {
try {
const name = decodeURIComponent(new URL(req.url, 'http://localhost').pathname).replace(/^\//, '') || 'index.html';
if (name.split('/').some((part) => part.startsWith('.'))) throw new Error('Private workspace path');
const file = resolveFile(root, name);
if (!fs.statSync(file).isFile()) throw new Error('Not a file');
res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
res.end(fs.readFileSync(file));
} catch (error) {
res.writeHead(error.code === 'ENOENT' ? 404 : 403);
res.end('Not available');
}
});
try {
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
} catch (error) {
await browser.close();
throw error;
}
const origin = `http://127.0.0.1:${server.address().port}`;
let context;
try {
context = await browser.newContext({ reducedMotion: 'reduce', serviceWorkers: 'block' });
await context.route('**/*', (route) => {
const url = route.request().url();
if (new URL(url).origin === origin || url.startsWith('data:')) return route.continue();
blockedRequests.push(url);
return route.abort();
});
} catch (error) {
await browser.close();
await new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); });
throw error;
}
return {
origin, blockedRequests,
environment: `Workspace: ${root}. A local server and Chromium are already running. browser_snapshot renders a workspace-relative HTML path at desktop/mobile size, saves a screenshot, and returns the actual image plus DOM text. view_image opens saved PNGs. No browser installation is needed. External browser requests are blocked; this text-only fixture uses system fonts. No image-generation or subagent tools are available.`,
tools(trace) {
return {
browser_snapshot: tool({
description: 'Render and inspect an HTML file with the ready Chromium browser. Returns an actual screenshot and visible DOM text; optionally click a CSS selector before capture. Captures save to .impeccable/review/{desktop|mobile}.png.',
inputSchema: z.object({ path: z.string(), viewport: z.enum(['desktop', 'mobile']), click: z.string().optional() }),
execute: async ({ path: target, viewport, click }) => {
const file = resolveFile(root, target);
if (!/\.html?$/i.test(file)) throw new Error('Expected an HTML artifact');
const relativeTarget = path.relative(fs.realpathSync(root), file).split(path.sep).join('/');
const call = { name: 'browser_snapshot', input: { path: target, viewport, click }, mutatedPaths: [] };
trace.toolCalls.push(call);
const page = await context.newPage();
page.setDefaultTimeout(10000);
try {
const sourceHash = hashSources(root);
await page.setViewportSize(VIEWPORTS[viewport]);
await page.goto(`${origin}/${relativeTarget.split('/').map(encodeURIComponent).join('/')}`, { waitUntil: 'load', timeout: 15000 });
await page.evaluate(() => document.fonts.ready);
if (click) await page.locator(click).click();
const screenshot = `.impeccable/review/${viewport}.png`;
if (fs.existsSync(path.join(root, '.impeccable'))) resolveFile(root, '.impeccable');
fs.mkdirSync(path.join(root, '.impeccable/review'), { recursive: true });
resolveFile(root, '.impeccable/review');
if (fs.existsSync(path.join(root, screenshot))) resolveFile(root, screenshot);
const image = await page.screenshot({ path: path.join(root, screenshot), fullPage: true, animations: 'disabled' });
call.mutatedPaths = [screenshot];
if (sourceHash !== hashSources(root)) throw new Error('Artifact changed during capture; retry');
call.capture = { target: relativeTarget, viewport, screenshot, sourceHash };
return { ...call.capture, text: (await page.locator('body').innerText()).slice(0, 12000), image: image.toString('base64') };
} finally {
await page.close();
}
},
toModelOutput: imageOutput,
}),
view_image: tool({
description: 'Inspect an existing workspace PNG as an actual image, not raw file bytes.',
inputSchema: z.object({ path: z.string() }),
execute: async ({ path: name }) => {
const bytes = fs.readFileSync(resolveFile(root, name));
if (!bytes.subarray(0, 8).equals(PNG)) throw new Error('Expected a PNG image');
trace.toolCalls.push({ name: 'view_image', input: { path: name }, mutatedPaths: [], loadedImages: [name] });
return { path: name, image: bytes.toString('base64') };
},
toModelOutput: imageOutput,
}),
};
},
async close() {
await browser.close();
await new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); });
},
};
}