95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { tool } from '@opencode-ai/plugin';
|
|
|
|
export default tool({
|
|
description:
|
|
'Thin wrapper over the agent-browser CLI. Opens the target URL at the requested viewport, optionally scrolls to a section, waits for networkidle plus a settle delay, then saves a cropped PNG screenshot. Used by clone-validate to capture rendered sections for diff against the captured originals.',
|
|
args: {
|
|
url: tool.schema.string().describe('URL to open (typically http://localhost:3000/)'),
|
|
viewport: tool.schema.number().describe('Viewport width in pixels (height is derived 16:9)'),
|
|
output_path: tool.schema.string().describe('Absolute path to write the PNG to'),
|
|
scroll_y: tool.schema
|
|
.number()
|
|
.nullable()
|
|
.describe('Absolute Y pixel position to scroll to before screenshot. Omit for no scroll.'),
|
|
crop: tool.schema
|
|
.object({
|
|
x: tool.schema.number(),
|
|
y: tool.schema.number(),
|
|
width: tool.schema.number(),
|
|
height: tool.schema.number(),
|
|
})
|
|
.nullable()
|
|
.describe('Crop region to extract from the viewport screenshot. Omit to keep full viewport.'),
|
|
reduce_motion: tool.schema
|
|
.boolean()
|
|
.nullable()
|
|
.describe('If true, adds the `reduce-motion` class to <html> before screenshot. Default false.'),
|
|
settle_ms: tool.schema
|
|
.number()
|
|
.nullable()
|
|
.describe('Extra ms to wait after networkidle before screenshot. Default 500.'),
|
|
},
|
|
async execute(args, context) {
|
|
const { spawn } = await import('node:child_process');
|
|
const { resolve, dirname } = await import('node:path');
|
|
const { mkdirSync, existsSync } = await import('node:fs');
|
|
const { resolvePython } = await import('./_python.js');
|
|
|
|
const python = resolvePython(context.worktree);
|
|
const outPath = resolve(context.worktree, args.output_path);
|
|
const outDir = dirname(outPath);
|
|
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
|
|
const height = Math.round((args.viewport * 9) / 16);
|
|
const session = `clone-validate-${args.viewport}`;
|
|
|
|
const steps: string[][] = [
|
|
['--session', session, 'set', 'viewport', String(args.viewport), String(height)],
|
|
['--session', session, 'open', args.url],
|
|
['--session', session, 'wait', '--load', 'networkidle'],
|
|
];
|
|
|
|
if (args.reduce_motion ?? false) {
|
|
steps.push(['--session', session, 'eval', 'document.documentElement.classList.add("reduce-motion")']);
|
|
}
|
|
|
|
if (args.scroll_y != null) {
|
|
steps.push(['--session', session, 'eval', `window.scrollTo({ top: ${args.scroll_y}, behavior: "instant" })`]);
|
|
}
|
|
|
|
steps.push(['--session', session, 'wait', String(args.settle_ms ?? 500)]);
|
|
steps.push(['--session', session, 'screenshot', outPath]);
|
|
|
|
for (const argv of steps) {
|
|
await new Promise<void>((resolvePromise, reject) => {
|
|
const child = spawn('agent-browser', argv, { stdio: 'inherit' });
|
|
child.on('exit', (code) => {
|
|
if (code === 0) resolvePromise();
|
|
else reject(new Error(`agent-browser ${argv.join(' ')} exited ${code}`));
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
if (args.crop != null) {
|
|
await new Promise<void>((resolvePromise, reject) => {
|
|
const child = spawn(
|
|
python,
|
|
[
|
|
'-c',
|
|
`from PIL import Image; import sys; im=Image.open(sys.argv[1]); im.crop((${args.crop!.x}, ${
|
|
args.crop!.y
|
|
}, ${args.crop!.x + args.crop!.width}, ${args.crop!.y + args.crop!.height})).save(sys.argv[1])`,
|
|
outPath,
|
|
],
|
|
{ stdio: 'inherit' }
|
|
);
|
|
child.on('exit', (code) => (code === 0 ? resolvePromise() : reject(new Error(`crop failed ${code}`))));
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
return JSON.stringify({ output_path: outPath, viewport: args.viewport });
|
|
},
|
|
});
|