diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d930a36f..1badc6d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + skill_workflow: + description: 'Run billed, browser-backed Claude workflow completion tests' + type: boolean + default: false # Nightly full live-e2e matrix. The smoke groups already gate every PR; the # full sweep is too slow for that, so it runs once a day against main. schedule: @@ -665,5 +670,44 @@ jobs: - name: Install dependencies run: bun install + - name: Prepare engine for protocol tests + run: bun run fetch:engine + - name: Run skill behavior tests run: bun run test:skill-behavior + + skill-workflow: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && inputs.skill_workflow + timeout-minutes: 70 + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + IMPECCABLE_SKILL_BEHAVIOR_MODELS: claude-sonnet-5 + IMPECCABLE_SKILL_BEHAVIOR_TRACE_DIR: ${{ runner.temp }}/skill-workflow-traces + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Prepare engine and browser before billing + run: | + test -n "$ANTHROPIC_API_KEY" || { echo 'ANTHROPIC_API_KEY is required'; exit 1; } + bun run fetch:engine + bunx playwright install --with-deps chromium + - name: Run completed skill workflows + run: bun run test:skill-workflow + - name: Retain diagnostic traces + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: skill-workflow-traces + path: ${{ runner.temp }}/skill-workflow-traces + retention-days: 7 diff --git a/package.json b/package.json index fe86c503b..68a6c7527 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "test:new-work-e2e": "node scripts/run-tests.mjs new-work-e2e", "test:live-e2e-agent": "node scripts/run-tests.mjs live-e2e-agent", "test:skill-behavior": "node scripts/run-tests.mjs skill-behavior", + "test:skill-workflow": "node scripts/run-tests.mjs skill-workflow", "test:live-svelte-adapter-deepseek": "node scripts/run-tests.mjs live-svelte-adapter-deepseek", "smoke:hooks": "node scripts/smoke-provider-hooks.mjs", "audit": "bun audit --audit-level=moderate", diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 21a3790de..58936d202 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -8,6 +8,7 @@ export const OPT_IN_SUITES = [ 'live-e2e-accept-cleanup', 'new-work-e2e', 'skill-behavior', + 'skill-workflow', 'live-svelte-adapter-deepseek', ]; @@ -267,38 +268,40 @@ export const SUITES = { ], }, 'skill-behavior': { - description: 'LLM-backed skill setup behavior scenarios.', + description: 'LLM-backed protocol checkpoints, not full builds.', optIn: true, triggers: [ ...COMMON_INFRA_PATTERNS, /^skill\/SKILL\.src\.md$/, - /^skill\/reference\/(init|document|brand|product|shape|craft|audit|polish|live|routing)\.md$/, + /^skill\/reference\//, /^ENGINE_VERSION$/, /^tests\/skill-behavior\//, ], + commands: [{ + runner: 'node', + timeoutMs: 240000, + wallClockMs: 1_800_000, + files: ['tests/skill-behavior/scenarios.test.mjs'], + }], + }, + 'skill-workflow': { + description: 'Explicitly opt-in completed workflows with a preflighted browser.', + optIn: true, + needsPlaywright: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^skill\//, + /^ENGINE_VERSION$/, + /^tests\/skill-workflow\//, + /^tests\/skill-behavior\//, + ], commands: [ + { runner: 'node', files: ['tests/skill-workflow-browser.test.mjs'] }, { runner: 'node', - // 300000 was too low to measure what these scenarios assert. The - // workflow-contract turns run 20+ steps against a frontier model, and - // the *correct* path is the slow one: a run that stops to put the - // concept to the user before building was measured at 579s, while the - // runs that skipped that checkpoint and failed the assertion finished - // in 130-200s. At a 300s cap the thorough path is killed and the hasty - // path is graded, so the cap was selecting for the behavior the suite - // exists to forbid. timeoutMs: 900000, - // Overall wall-clock safety cap for the whole sweep: if a provider - // call wedges past every inner guard (the harness's 840s per-turn - // AbortSignal and the 900s per-test timeout), the runner SIGKILLs the - // process group so the sweep still ends with a per-provider tally - // instead of hanging overnight. Sized well above a healthy two-provider - // sweep; override with IMPECCABLE_TEST_WALL_CLOCK_MS to scope it down. wallClockMs: 3_600_000, - files: [ - 'tests/skill-behavior/scenarios.test.mjs', - 'tests/skill-behavior/workflow-contract.test.mjs', - ], + files: ['tests/skill-workflow/full-build.test.mjs'], }, ], }, diff --git a/tests/ci-test-plan.test.mjs b/tests/ci-test-plan.test.mjs index d87c53aa1..5e1154349 100644 --- a/tests/ci-test-plan.test.mjs +++ b/tests/ci-test-plan.test.mjs @@ -8,6 +8,16 @@ import { tmpdir } from 'node:os'; const SCRIPT = 'scripts/ci-test-plan.mjs'; describe('ci-test-plan', () => { + it('requires explicit manual opt-in and preprovisions the full workflow job', () => { + const workflow = readFileSync('.github/workflows/ci.yml', 'utf8'); + assert.match(workflow, /skill_workflow:\s*description:[^\n]+\s*type: boolean\s*default: false/); + const job = workflow.split('\n skill-workflow:')[1]; + assert.match(job, /if: github.event_name == 'workflow_dispatch' && inputs.skill_workflow/); + assert.ok(job.indexOf('bun run fetch:engine') < job.indexOf('bun run test:skill-workflow')); + assert.ok(job.indexOf('playwright install --with-deps chromium') < job.indexOf('bun run test:skill-workflow')); + const protocol = workflow.split('\n skill-behavior:')[1].split('\n skill-workflow:')[0]; + assert.match(protocol, /bun run fetch:engine/); + }); it('keeps docs-only pull requests on the core suite', () => { const outputs = runPlan({ GITHUB_EVENT_NAME: 'pull_request', diff --git a/tests/skill-behavior-harness.test.mjs b/tests/skill-behavior-harness.test.mjs index 074b1d43d..797317e34 100644 --- a/tests/skill-behavior-harness.test.mjs +++ b/tests/skill-behavior-harness.test.mjs @@ -6,6 +6,30 @@ import { MockLanguageModelV3 } from 'ai/test'; import { prepareWorkspace, cleanupWorkspace, makeTools, runTurn, fileLoaded, SKILL_BODY } from './skill-behavior/harness.mjs'; import { assertPlanningFallbackWarning, assertNewWorkLifecycle } from './skill-behavior/assertions.mjs'; import { CASE_STUDY_ANSWER } from './skill-behavior/fixtures.mjs'; +import { sourceHash as hashSources } from './skill-workflow/source-hash.mjs'; +import { assertCompleted, assertFreshCaptures } from './skill-workflow/assertions.mjs'; + +it('full workflows reject exhausted budgets and stale or absent visual evidence', () => { + for (const outcome of ['checkpoint', 'step-budget', 'output-limit', 'error']) { + assert.throws(() => assertCompleted({ outcome, steps: 50 }), /did not finish/); + } + assert.doesNotThrow(() => assertCompleted({ outcome: 'complete', steps: 12 })); + const workspace = prepareWorkspace({ files: { 'index.html': '
');
+ browser = await prepareBrowser(root);
+ const trace = { toolCalls: [] };
+ const tools = browser.tools(trace);
+ const capture = await tools.browser_snapshot.execute({ path: './index.html', viewport: 'desktop', click: 'button' });
+ assert.equal(capture.target, 'index.html');
+ assert.match(capture.text, /Done/);
+ assert.ok(fs.existsSync(path.join(root, capture.screenshot)));
+ assert.equal(capture.viewport, 'desktop');
+ assert.equal(trace.toolCalls[0].name, 'browser_snapshot');
+ const output = imageOutput({ output: capture });
+ assert.equal(output.type, 'content');
+ assert.ok(output.value.some((part) => part.mediaType === 'image/png'));
+ const viewed = await tools.view_image.execute({ path: capture.screenshot });
+ assert.equal(viewed.image, capture.image);
+ const captures = await Promise.all(['desktop', 'mobile'].map((viewport) => tools.browser_snapshot.execute({ path: 'index.html', viewport })));
+ assert.deepEqual(captures.map((result) => result.viewport), ['desktop', 'mobile']);
+ assert.notEqual(captures[0].image, captures[1].image, 'parallel viewports must not share mutable page state');
+ assert.ok(browser.blockedRequests.some((url) => url.includes('example.invalid')));
+ await assert.rejects(tools.browser_snapshot.execute({ path: '../outside.html', viewport: 'mobile' }), /workspace/);
+ await assert.rejects(tools.view_image.execute({ path: 'index.html' }), /PNG/);
+ fs.symlinkSync(os.tmpdir(), path.join(root, 'escape'));
+ const response = await fetch(`${browser.origin}/escape/`);
+ assert.equal(response.status, 403);
+ } finally {
+ await browser?.close();
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/tests/skill-workflow/assertions.mjs b/tests/skill-workflow/assertions.mjs
new file mode 100644
index 000000000..541c15190
--- /dev/null
+++ b/tests/skill-workflow/assertions.mjs
@@ -0,0 +1,17 @@
+import assert from 'node:assert/strict';
+import { sourceHash } from './source-hash.mjs';
+
+export function assertCompleted(result) {
+ assert.equal(result.outcome, 'complete', `workflow did not finish: ${result.outcome} after ${result.steps} steps`);
+}
+
+export function assertFreshCaptures(trace, workspace, target) {
+ const calls = trace.toolCalls;
+ const lastEdit = calls.findLastIndex((call) => (call.mutatedPaths || []).includes(target));
+ const hash = sourceHash(workspace);
+ for (const viewport of ['desktop', 'mobile']) {
+ assert.ok(calls.some((call, index) => index > lastEdit && call.capture?.target === target
+ && call.capture.viewport === viewport && call.capture.sourceHash === hash),
+ `missing ${viewport} screenshot of the final ${target}; pre-edit captures do not count`);
+ }
+}
diff --git a/tests/skill-workflow/browser.mjs b/tests/skill-workflow/browser.mjs
new file mode 100644
index 000000000..08644fc95
--- /dev/null
+++ b/tests/skill-workflow/browser.mjs
@@ -0,0 +1,131 @@
+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(); });
+ },
+ };
+}
diff --git a/tests/skill-behavior/workflow-contract.test.mjs b/tests/skill-workflow/full-build.test.mjs
similarity index 82%
rename from tests/skill-behavior/workflow-contract.test.mjs
rename to tests/skill-workflow/full-build.test.mjs
index 27fd0e99c..1c2d1282b 100644
--- a/tests/skill-behavior/workflow-contract.test.mjs
+++ b/tests/skill-workflow/full-build.test.mjs
@@ -10,15 +10,39 @@ import path from 'node:path';
import {
prepareWorkspace,
cleanupWorkspace,
- runTurn,
+ runTurn as runHarnessTurn,
fileLoaded,
summarizeTrace,
ENGINE_BIN,
ENGINE_MISSING_MESSAGE,
-} from './harness.mjs';
-import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from './providers.mjs';
-import { assertNewWorkLifecycle } from './assertions.mjs';
-import { PRODUCT_MD_SAMPLE, DESIGN_MD_SAMPLE, CASE_STUDY_ANSWER } from './fixtures.mjs';
+} from '../skill-behavior/harness.mjs';
+import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from '../skill-behavior/providers.mjs';
+import { assertNewWorkLifecycle } from '../skill-behavior/assertions.mjs';
+import { PRODUCT_MD_SAMPLE, DESIGN_MD_SAMPLE as ORIGINAL_DESIGN, CASE_STUDY_ANSWER } from '../skill-behavior/fixtures.mjs';
+import { prepareBrowser } from './browser.mjs';
+import { assertCompleted, assertFreshCaptures } from './assertions.mjs';
+
+const DESIGN_MD_SAMPLE = ORIGINAL_DESIGN.replace(/GT Sectra \(commercial\)/g, 'Georgia (system)').replace(/JetBrains Mono/g, 'monospace').replace(/Inter/g, 'Arial');
+
+async function runTurn(options) {
+ // Preflight happens before the first provider call. These are text-only
+ // HTML fixtures: no dependencies, font downloads, or browser discovery.
+ const browser = await prepareBrowser(options.workspace);
+ try {
+ const result = await runHarnessTurn({
+ ...options, maxSteps: 50, timeoutMs: 840000,
+ userPrompt: `${options.userPrompt}\nUse system fonts and no external assets for this text-only fixture. The browser_snapshot and view_image tools are ready for visual review.`,
+ environment: browser.environment,
+ additionalTools: (trace) => browser.tools(trace),
+ });
+ assertCompleted(result);
+ const contextCalls = result.trace.toolCalls.filter(({ name, input }) => name === 'bash' && /impeccable\s+context\b/.test(input.command));
+ assert.equal(contextCalls.length, 1, 'completed workflow must load context exactly once');
+ return result;
+ } finally {
+ await browser.close();
+ }
+}
const LEGACY_DESIGN = `# Design
@@ -101,7 +125,9 @@ function workflowTraceMessage(trace) {
return JSON.stringify(summarizeTrace(trace), null, 2);
}
-for (const modelId of resolveModelList()) {
+// Full builds are separately opt-in and default to one provider. The existing
+// model selection variable can explicitly request a cross-provider sweep.
+for (const modelId of process.env.IMPECCABLE_SKILL_BEHAVIOR_MODELS ? resolveModelList() : ['claude-sonnet-5']) {
const provider = detectProvider(modelId);
const keyPresent = hasKey(provider);
@@ -123,7 +149,6 @@ for (const modelId of resolveModelList()) {
workspace,
model,
userPrompt: '/impeccable init for a harbor operations product, then finish setup.',
- maxSteps: 24,
});
const question = firstCall(trace, ({ name }) => name === 'ask_user_question');
const productWrite = firstMutation(trace, /(^|\/)PRODUCT\.md$/i);
@@ -149,12 +174,14 @@ for (const modelId of resolveModelList()) {
model,
userPrompt: '/impeccable create a concise evidence-led case-study page. Leave it at index.html.',
simulatedUser: { answer: () => CASE_STUDY_ANSWER },
- maxSteps: 22,
});
const question = firstCall(trace, ({ name }) => name === 'ask_user_question');
assert.ok(fileLoaded(trace, 'new-work.md'), `new-work.md was not loaded.\n${workflowTraceMessage(trace)}`);
assert.ok(question >= 0, `task concept was never put to the user.\n${workflowTraceMessage(trace)}`);
assertNewWorkLifecycle(trace, { target: 'index.html' });
+ assertFreshCaptures(trace, workspace, 'index.html');
+ assert.ok(fileLoaded(trace, 'finish-reviewer.md'), 'new-work must run the shipped finish review');
+ assert.ok(fileLoaded(trace, 'documenter.md'), 'new-work must run the shipped documentation pass');
assert.equal(fs.existsSync(path.join(workspace, 'index.html')), true, 'new-work must still produce the requested artifact');
} finally {
cleanupWorkspace(workspace);
@@ -174,12 +201,14 @@ for (const modelId of resolveModelList()) {
workspace,
model,
userPrompt: '/impeccable redesign current.html for this product. Leave the result at current.html.',
- maxSteps: 26,
});
const question = firstCall(trace, ({ name }) => name === 'ask_user_question');
assert.ok(fileLoaded(trace, 'new-work.md'), `redesign did not route through new-work.\n${workflowTraceMessage(trace)}`);
assert.ok(question >= 0, `replacement world was not put to the user.\n${workflowTraceMessage(trace)}`);
assertNewWorkLifecycle(trace, { target: 'current.html', redesign: true });
+ assertFreshCaptures(trace, workspace, 'current.html');
+ assert.ok(fileLoaded(trace, 'finish-reviewer.md'), 'redesign must run the shipped finish review');
+ assert.ok(fileLoaded(trace, 'documenter.md'), 'redesign must run the shipped documentation pass');
const design = fs.readFileSync(path.join(workspace, 'DESIGN.md'), 'utf8');
assert.notEqual(design.trim(), LEGACY_DESIGN.trim(), 'redesign preserved the old visual world verbatim');
} finally {
@@ -200,7 +229,6 @@ for (const modelId of resolveModelList()) {
workspace,
model,
userPrompt: '/impeccable bolder current.html, only the #case-study section. Keep everything else untouched.',
- maxSteps: 16,
});
const productWrite = firstMutation(trace, /(^|\/)PRODUCT\.md$/i);
const designWrite = firstMutation(trace, /(^|\/)DESIGN\.md$/i);
@@ -209,6 +237,7 @@ for (const modelId of resolveModelList()) {
assert.equal(productWrite, -1, `refinement rewrote PRODUCT.md.\n${workflowTraceMessage(trace)}`);
assert.equal(designWrite, -1, `refinement rewrote DESIGN.md.\n${workflowTraceMessage(trace)}`);
assert.ok(implementation >= 0, `refinement did not write current.html.\n${workflowTraceMessage(trace)}`);
+ assertFreshCaptures(trace, workspace, 'current.html');
const artifact = fs.readFileSync(path.join(workspace, 'current.html'), 'utf8');
assert.match(artifact, /data-untouched="header"/);
assert.match(artifact, /data-untouched="footer"/);
@@ -236,9 +265,9 @@ for (const modelId of resolveModelList()) {
workspace,
model,
userPrompt: '/impeccable critique current.html',
- maxSteps: 30,
});
assert.ok(fileLoaded(trace, 'critique.md'), `critique.md was not loaded.\n${workflowTraceMessage(trace)}`);
+ assertFreshCaptures(trace, workspace, 'current.html');
const parts = assistantParts(responseMessages);
const allText = parts.filter((p) => p.kind === 'text').map((p) => p.value).join('\n');
diff --git a/tests/skill-workflow/source-hash.mjs b/tests/skill-workflow/source-hash.mjs
new file mode 100644
index 000000000..528aa8ba9
--- /dev/null
+++ b/tests/skill-workflow/source-hash.mjs
@@ -0,0 +1,22 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+
+// Include local styles/scripts/assets too: an unchanged HTML file is not
+// evidence of a current capture when an external stylesheet changed.
+export function sourceHash(root) {
+ const hash = crypto.createHash('sha256');
+ function visit(directory, prefix = '') {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
+ const relative = `${prefix}${entry.name}`;
+ const file = path.join(directory, entry.name);
+ if (entry.isDirectory()) visit(file, `${relative}/`);
+ else if (entry.isFile() && /\.(html?|css|m?js|svg|png|jpe?g|webp|woff2?)$/i.test(entry.name)) {
+ hash.update(relative).update('\0').update(fs.readFileSync(file)).update('\0');
+ }
+ }
+ }
+ visit(root);
+ return hash.digest('hex');
+}
diff --git a/tests/test-suites.test.mjs b/tests/test-suites.test.mjs
index d2621b52a..e69cd6b04 100644
--- a/tests/test-suites.test.mjs
+++ b/tests/test-suites.test.mjs
@@ -12,6 +12,13 @@ import {
} from '../scripts/test-suites.mjs';
describe('test suite registry', () => {
+ it('separates protocol checkpoints from opt-in browser-backed completion', () => {
+ assert.deepEqual(suiteFiles(['skill-behavior']), ['tests/skill-behavior/scenarios.test.mjs']);
+ assert.ok(OPT_IN_SUITES.includes('skill-workflow'));
+ assert.equal(SUITES['skill-workflow'].needsPlaywright, true);
+ assert.ok(suiteFiles(['skill-workflow']).includes('tests/skill-workflow/full-build.test.mjs'));
+ assert.equal(DEFAULT_SUITES.includes('skill-workflow'), false);
+ });
it('assigns every test file to a default or opt-in suite', () => {
const allDiscovered = findTestFiles();
const allRegistered = new Set(suiteFiles([...DEFAULT_SUITES, ...OPT_IN_SUITES]));