mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 18:47:02 +03:00
Add /impeccable generate: agent-initiated live variants (Node-era squash)
Squash of the ten commits reviewed on PR #626, plus the last review round's connection-aware roll call, before the rebase onto the Rust engine: the generate command reference and router row, the overlay's agent-target handling (roll call, leases, replay, rescue), the Node-era live-server routes and live-generate CLI, the hook stand-down, the pricing cards e2e fixture, and the unit, contract, e2e, and skill-behavior tests. The server, CLI, hook, and pin halves are ported to the engine crates in the commits that follow. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5
parent
1c043ea7c9
commit
fc89b0ed62
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite 8 + Pricing Cards Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "vite8-react-pricing-cards-fixture",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const TIERS = [
|
||||
{ id: 'starter', name: 'Starter', price: '$19/mo', blurb: 'For a single project and one seat.' },
|
||||
{ id: 'studio', name: 'Studio', price: '$49/mo', blurb: 'For small teams shipping every week.' },
|
||||
{ id: 'atelier', name: 'Atelier', price: '$120/mo', blurb: 'For agencies running many brands.' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1 className="hero-heading">A tall hero keeps the pricing far below the fold.</h1>
|
||||
<p className="hero-hook">
|
||||
The agent-target scenario must scroll the pricing section into view on its own.
|
||||
</p>
|
||||
</section>
|
||||
<section className="pricing" id="pricing">
|
||||
<h2 className="pricing-title">Simple pricing</h2>
|
||||
<div className="pricing-grid">
|
||||
{TIERS.map((tier) => (
|
||||
<article key={tier.id} className="pricing-card">
|
||||
<h3>{tier.name}</h3>
|
||||
<p className="tier-price">{tier.price}</p>
|
||||
<p>{tier.blurb}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
body { margin: 0; font-family: system-ui, sans-serif; color: #1d2229; }
|
||||
.page { max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
.hero { min-height: 160vh; display: flex; flex-direction: column; justify-content: center; }
|
||||
.hero-heading { font-size: 40px; max-width: 18ch; }
|
||||
.hero-hook { color: #55606e; }
|
||||
.pricing { padding: 80px 0 120px; }
|
||||
.pricing-title { font-size: 32px; }
|
||||
.pricing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
.pricing-card { border: 1px solid #d8dee7; border-radius: 10px; padding: 20px; }
|
||||
.tier-price { font-weight: 700; }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: '127.0.0.1', strictPort: false },
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Vite 8 + React + pricing cards below the fold (agent-initiated target)",
|
||||
"config": {
|
||||
"files": ["index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["index.html", "src/App.jsx", "src/main.jsx", "src/styles.css", "vite.config.js"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps the below-the-fold pricing title in source JSX",
|
||||
"args": { "classes": "pricing-title", "tag": "h2" },
|
||||
"expectedFile": "src/App.jsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"steer": false,
|
||||
"pickSelector": "h2.pricing-title",
|
||||
"acceptedSourcePattern": "<h2[^>]*(class|className)=\"[^\"]*\\bpricing-title\\b",
|
||||
"assertSourceContains": ["{tier.name}"],
|
||||
"agentTargetScenario": {
|
||||
"selector": "h2.pricing-title",
|
||||
"action": "bolder",
|
||||
"count": 3,
|
||||
"prompt": "keep it monochrome",
|
||||
"minScrollY": 300,
|
||||
"ambiguousSelector": ".pricing-card",
|
||||
"missSelector": ".does-not-exist"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* Tests for agent-initiated element targeting (the `generate` command):
|
||||
* POST /agent-target held-open pairing with POST /agent-target-result,
|
||||
* validation, the no-browser and timeout verdicts, and the live-generate CLI's
|
||||
* local failure modes.
|
||||
*
|
||||
* Run with: node --test tests/live-agent-target.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getLiveServerPath } from '../skill/scripts/lib/impeccable-paths.mjs';
|
||||
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
|
||||
|
||||
// Resolve the repo from this file, not from cwd: the runner may be invoked
|
||||
// from tests/ or anywhere else.
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs');
|
||||
const GENERATE_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-generate.mjs');
|
||||
|
||||
function startServer(port, { cwd, env = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env },
|
||||
});
|
||||
let output = '';
|
||||
proc.stdout.on('data', (d) => {
|
||||
output += d.toString();
|
||||
if (output.includes('running on')) {
|
||||
try {
|
||||
const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8'));
|
||||
resolve({ proc, port: info.port, token: info.token, cwd });
|
||||
} catch {
|
||||
reject(new Error('Server started but PID file not readable'));
|
||||
}
|
||||
}
|
||||
});
|
||||
proc.stderr.on('data', (d) => { output += d.toString(); });
|
||||
proc.on('error', reject);
|
||||
setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
try {
|
||||
await fetch(`http://localhost:${server.port}/stop?token=${server.token}`);
|
||||
} catch { /* already gone */ }
|
||||
}
|
||||
|
||||
function postJson(server, path, body) {
|
||||
return fetch(`http://localhost:${server.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal fake overlay: holds the SSE stream open and resolves pushed
|
||||
* messages so a test can await the next one matching a predicate.
|
||||
*/
|
||||
async function openSseClient(server, { clientId } = {}) {
|
||||
const controller = new AbortController();
|
||||
const res = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}` + (clientId ? `&clientId=${clientId}` : ''),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const messages = [];
|
||||
const waiters = [];
|
||||
let buffer = '';
|
||||
(async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
||||
const frame = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
let msg;
|
||||
try { msg = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
||||
messages.push(msg);
|
||||
for (let i = waiters.length - 1; i >= 0; i -= 1) {
|
||||
if (waiters[i].match(msg)) {
|
||||
waiters[i].resolve(msg);
|
||||
waiters.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* stream closed */ }
|
||||
})();
|
||||
return {
|
||||
messages,
|
||||
next(match, timeoutMs = 5000) {
|
||||
const found = messages.find(match);
|
||||
if (found) return Promise.resolve(found);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('SSE message timeout')), timeoutMs);
|
||||
waiters.push({ match, resolve: (m) => { clearTimeout(timer); resolve(m); } });
|
||||
});
|
||||
},
|
||||
close() { controller.abort(); },
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /agent-target', () => {
|
||||
let tmp;
|
||||
let server;
|
||||
|
||||
before(async () => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-agent-target-'));
|
||||
mkdirSync(join(tmp, '.impeccable/live'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'index.html'), '<html><body><h1>t</h1></body></html>');
|
||||
// A short timeout keeps the browser_timeout case fast; the env override
|
||||
// exists exactly for this.
|
||||
server = await startServer(8497, {
|
||||
cwd: tmp,
|
||||
env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '400' },
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopServer(server);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects a wrong token with 401', async () => {
|
||||
const res = await postJson(server, '/agent-target', {
|
||||
token: 'nope', selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('rejects an invalid action with 400 naming the vocabulary', async () => {
|
||||
const res = await postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bold', count: 3,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.match(body.error, /invalid action/);
|
||||
assert.match(body.error, /bolder/);
|
||||
});
|
||||
|
||||
it('rejects an out-of-range count with 400', async () => {
|
||||
const res = await postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 9,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.match(body.error, /count must be 1-8/);
|
||||
});
|
||||
|
||||
it('rejects a missing selector with 400', async () => {
|
||||
const res = await postJson(server, '/agent-target', {
|
||||
token: server.token, action: 'bolder', count: 3,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.match(body.error, /selector is required/);
|
||||
});
|
||||
|
||||
it('answers no_browser_connected when no SSE client is attached', async () => {
|
||||
const res = await postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.equal(body.error, 'no_browser_connected');
|
||||
});
|
||||
|
||||
it('broadcasts agent_target and resolves the held request with the browser result', async () => {
|
||||
const sse = await openSseClient(server);
|
||||
try {
|
||||
await sse.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token,
|
||||
selector: 'section.pricing',
|
||||
text: 'Studio',
|
||||
index: 2,
|
||||
action: 'bolder',
|
||||
count: 3,
|
||||
prompt: 'warmer',
|
||||
dryRun: true,
|
||||
});
|
||||
const pushed = await sse.next((m) => m.type === 'agent_target');
|
||||
assert.equal(pushed.selector, 'section.pricing');
|
||||
assert.equal(pushed.text, 'Studio');
|
||||
assert.equal(pushed.index, 2);
|
||||
assert.equal(pushed.action, 'bolder');
|
||||
assert.equal(pushed.count, 3);
|
||||
assert.equal(pushed.prompt, 'warmer');
|
||||
assert.equal(pushed.dryRun, true);
|
||||
assert.match(pushed.targetId, /^[0-9a-f]{8}$/);
|
||||
|
||||
const resultRes = await postJson(server, '/agent-target-result', {
|
||||
token: server.token,
|
||||
targetId: pushed.targetId,
|
||||
ok: true,
|
||||
matchCount: 1,
|
||||
sessionId: 'aabbccdd',
|
||||
element: { tag: 'section', id: null, classes: ['pricing'], text: 'Three ways' },
|
||||
});
|
||||
assert.deepEqual(await resultRes.json(), { ok: true, delivered: true });
|
||||
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.equal(verdict.targetId, pushed.targetId);
|
||||
assert.equal(verdict.matchCount, 1);
|
||||
assert.equal(verdict.sessionId, 'aabbccdd');
|
||||
assert.equal(verdict.element.tag, 'section');
|
||||
// The browser's own token must never leak back into the verdict.
|
||||
assert.equal('token' in verdict, false);
|
||||
} finally {
|
||||
sse.close();
|
||||
// Give the server's 8s SSE-drop exit timer no chance to fire between
|
||||
// tests: reconnecting tests open their own client immediately.
|
||||
}
|
||||
});
|
||||
|
||||
it('grants an agent-target claim exactly once, so one visible tab owns the request', async () => {
|
||||
const sse = await openSseClient(server);
|
||||
try {
|
||||
await sse.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await sse.next((m) => m.type === 'agent_target');
|
||||
const first = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
const second = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
const renew = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(first, { ok: true, granted: true, pending: true });
|
||||
assert.deepEqual(second, { ok: true, granted: false, pending: true });
|
||||
assert.deepEqual(renew, { ok: true, granted: true, pending: true }, 'the holder renews its own lease');
|
||||
// Settle the held request so the suite never waits out the timeout.
|
||||
await postJson(server, '/agent-target-result', {
|
||||
token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
||||
});
|
||||
await (await held).json();
|
||||
} finally {
|
||||
sse.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reopens the claim after the winner lease lapses, so a surviving tab can rescue', async () => {
|
||||
// Own server: the shared one keeps the default 3s claim lease, which its
|
||||
// 400ms target timeout would delete long before the lease could lapse.
|
||||
const tmp2 = mkdtempSync(join(tmpdir(), 'impeccable-agent-lease-'));
|
||||
mkdirSync(join(tmp2, '.impeccable/live'), { recursive: true });
|
||||
writeFileSync(join(tmp2, 'index.html'), '<html><body><h1>t</h1></body></html>');
|
||||
const leaseServer = await startServer(8495, {
|
||||
cwd: tmp2,
|
||||
env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '2000', IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS: '250' },
|
||||
});
|
||||
const sse = await openSseClient(leaseServer);
|
||||
try {
|
||||
await sse.next((m) => m.type === 'connected');
|
||||
const held = postJson(leaseServer, '/agent-target', {
|
||||
token: leaseServer.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await sse.next((m) => m.type === 'agent_target');
|
||||
const win = await (await postJson(leaseServer, '/agent-target-claim', {
|
||||
token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
const deniedInsideLease = await (await postJson(leaseServer, '/agent-target-claim', {
|
||||
token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
assert.equal(win.granted, true);
|
||||
assert.equal(deniedInsideLease.granted, false);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
const rescue = await (await postJson(leaseServer, '/agent-target-claim', {
|
||||
token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
assert.equal(rescue.granted, true, 'a lapsed lease reopens the claim');
|
||||
const staleRenew = await (await postJson(leaseServer, '/agent-target-claim', {
|
||||
token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
assert.equal(staleRenew.granted, false, 'the lapsed holder cannot renew once a rescuer holds the lease');
|
||||
await postJson(leaseServer, '/agent-target-result', {
|
||||
token: leaseServer.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
||||
});
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, true);
|
||||
} finally {
|
||||
sse.close();
|
||||
await stopServer(leaseServer);
|
||||
rmSync(tmp2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('denies a claim for an unknown or already-resolved targetId', async () => {
|
||||
const res = await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: 'deadbeef', clientId: 'tab-x', eligible: true,
|
||||
});
|
||||
assert.deepEqual(await res.json(), { ok: true, granted: false, pending: false }, 'and says the request is gone, which ends a rescue loop');
|
||||
});
|
||||
|
||||
it('answers busy as soon as every connected overlay has reported busy', async () => {
|
||||
// Roll call: two tabs, both mid-session. Neither claims; each reports
|
||||
// ineligible, and the second report completes the roll call, so the
|
||||
// held request answers busy well inside the 400ms target timeout.
|
||||
const tabA = await openSseClient(server);
|
||||
const tabB = await openSseClient(server);
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const startedAt = Date.now();
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
||||
for (const clientId of ['tab-a', 'tab-b']) {
|
||||
const report = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
})).json();
|
||||
assert.deepEqual(report, { ok: true, granted: false });
|
||||
}
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.error, 'busy');
|
||||
assert.equal(verdict.state, 'CYCLING');
|
||||
assert.equal(verdict.reason, 'session_active');
|
||||
assert.ok(Date.now() - startedAt < 350, 'the busy verdict did not wait for the timeout');
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets an eligible tab serve the request while another tab reports busy', async () => {
|
||||
const tabA = await openSseClient(server);
|
||||
const tabB = await openSseClient(server);
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
});
|
||||
const claim = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(claim, { ok: true, granted: true, pending: true }, 'one busy report does not close a roll call with an idle tab left');
|
||||
await postJson(server, '/agent-target-result', {
|
||||
token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
||||
});
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.equal(verdict.sessionId, 'aabbccdd');
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('completes the roll call when the holder itself turns busy', async () => {
|
||||
// The holder claimed, then went busy before acting. Its busy report must
|
||||
// hand the lease back, so the other tab's report completes the roll call
|
||||
// and the CLI gets busy now, not at the 15s timeout.
|
||||
const tabA = await openSseClient(server);
|
||||
const tabB = await openSseClient(server);
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const startedAt = Date.now();
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
||||
const claim = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(claim, { ok: true, granted: true, pending: true });
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'GENERATING', reason: 'session_active',
|
||||
});
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
});
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.error, 'busy');
|
||||
assert.ok(Date.now() - startedAt < 350, 'the holder handing the lease back let the roll call complete');
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('withdraws a stale busy report once that tab claims as eligible', async () => {
|
||||
// Tab A reported busy, then freed up and claimed as eligible before tab B
|
||||
// reported. B's late busy report must not resolve the request as busy on
|
||||
// A's stale word; A holds the lease and serves it.
|
||||
const tabA = await openSseClient(server);
|
||||
const tabB = await openSseClient(server);
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
});
|
||||
const reclaim = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(reclaim, { ok: true, granted: true, pending: true }, 'the freed tab takes the lease');
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
});
|
||||
// Still pending: the stale report was withdrawn, so B's report alone
|
||||
// does not complete the roll call. A's result resolves it.
|
||||
const resultRes = await postJson(server, '/agent-target-result', {
|
||||
token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
||||
});
|
||||
assert.deepEqual(await resultRes.json(), { ok: true, delivered: true });
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.equal(verdict.sessionId, 'aabbccdd');
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('tells a denied claimant whether the request is still pending, so rescue retries stop once it is gone', async () => {
|
||||
// The holder claims and never posts a result. The loser's denied claim
|
||||
// says the request is still pending, so it keeps retrying; once the
|
||||
// request times out, the answer says it is gone and the retry loop ends.
|
||||
const tab = await openSseClient(server);
|
||||
try {
|
||||
await tab.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tab.next((m) => m.type === 'agent_target');
|
||||
const holder = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(holder, { ok: true, granted: true, pending: true });
|
||||
const denied = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(denied, { ok: true, granted: false, pending: true }, 'a live request keeps the loser retrying');
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.error, 'browser_timeout');
|
||||
const late = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(late, { ok: true, granted: false, pending: false }, 'a resolved request ends the retry loop');
|
||||
} finally {
|
||||
tab.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('replays a pending target to an overlay that connects after the broadcast', async () => {
|
||||
// Tab A hears the broadcast and stays silent. Tab B connects afterwards
|
||||
// (a reload mid-request): it must receive the same target, so it can
|
||||
// claim and serve instead of only widening the roll call's count.
|
||||
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
||||
let tabB = null;
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
||||
tabB = await openSseClient(server, { clientId: 'tab-b' });
|
||||
const replayed = await tabB.next((m) => m.type === 'agent_target');
|
||||
assert.equal(replayed.targetId, pushed.targetId, 'the late overlay is told about the pending target');
|
||||
assert.equal(replayed.selector, 'h1');
|
||||
const claim = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
assert.deepEqual(claim, { ok: true, granted: true, pending: true });
|
||||
await postJson(server, '/agent-target-result', {
|
||||
token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
||||
});
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.equal(verdict.sessionId, 'aabbccdd');
|
||||
} finally {
|
||||
tabA.close();
|
||||
if (tabB) tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('hands a disconnected holder\'s lease back at once', async () => {
|
||||
// Tab A claims and then disconnects (reload, closed tab). Its lease must
|
||||
// not have to lapse: tab B's next claim is granted right away.
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
||||
const tabB = await openSseClient(server, { clientId: 'tab-b' });
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabB.next((m) => m.type === 'agent_target');
|
||||
const holder = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
||||
})).json();
|
||||
assert.equal(holder.granted, true);
|
||||
tabA.close();
|
||||
let claim = { granted: false };
|
||||
for (let i = 0; i < 20 && !claim.granted; i += 1) {
|
||||
await sleep(15);
|
||||
claim = await (await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
||||
})).json();
|
||||
}
|
||||
assert.equal(claim.granted, true, 'the disconnect released the lease well inside the 3s lease and the 400ms timeout');
|
||||
await postJson(server, '/agent-target-result', {
|
||||
token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
||||
});
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, true);
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('retires a disconnected overlay\'s busy report instead of answering busy on its stale word', async () => {
|
||||
// Tab A reports busy and leaves; tab B stays silent. The timeout must
|
||||
// answer browser_timeout: the only busy word came from a tab that is gone.
|
||||
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
||||
const tabB = await openSseClient(server, { clientId: 'tab-b' });
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabB.next((m) => m.type === 'agent_target');
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
});
|
||||
tabA.close();
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.error, 'browser_timeout');
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('completes the roll call when the last silent overlay disconnects', async () => {
|
||||
// Tab A reported busy; tab B never answered and then left. Every overlay
|
||||
// still connected has declined, so the verdict is busy now, not at the
|
||||
// timeout.
|
||||
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
||||
const tabB = await openSseClient(server, { clientId: 'tab-b' });
|
||||
try {
|
||||
await tabA.next((m) => m.type === 'connected');
|
||||
await tabB.next((m) => m.type === 'connected');
|
||||
const startedAt = Date.now();
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'bolder', count: 3,
|
||||
});
|
||||
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
||||
await postJson(server, '/agent-target-claim', {
|
||||
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active',
|
||||
});
|
||||
tabB.close();
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.error, 'busy');
|
||||
assert.equal(verdict.reason, 'session_active');
|
||||
assert.ok(Date.now() - startedAt < 350, 'the disconnect completed the roll call before the timeout');
|
||||
} finally {
|
||||
tabA.close();
|
||||
tabB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('carries every action in the live vocabulary from the CLI through the push', async () => {
|
||||
// The generate command promises the whole action picker, Freeform through
|
||||
// Overdrive. Drive each value through the CLI and the server, and read it
|
||||
// back off the broadcast the overlay would act on.
|
||||
const sse = await openSseClient(server);
|
||||
try {
|
||||
await sse.next((m) => m.type === 'connected');
|
||||
for (const action of VISUAL_ACTIONS) {
|
||||
const cli = new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[GENERATE_SCRIPT, '--selector', 'h1', '--action', action, '--dry-run'],
|
||||
{ cwd: tmp, encoding: 'utf-8' },
|
||||
(err, stdout) => resolve({ code: err ? err.code : 0, stdout }),
|
||||
);
|
||||
});
|
||||
const pushed = await sse.next(
|
||||
(m) => m.type === 'agent_target' && m.action === action && m.dryRun === true,
|
||||
10_000,
|
||||
);
|
||||
await postJson(server, '/agent-target-result', {
|
||||
token: server.token,
|
||||
targetId: pushed.targetId,
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
matchCount: 1,
|
||||
element: { tag: 'h1', id: null, classes: [], text: 't' },
|
||||
});
|
||||
const { code, stdout } = await cli;
|
||||
const verdict = JSON.parse(stdout);
|
||||
assert.equal(code, 0, `${action}: the CLI exits 0`);
|
||||
assert.equal(verdict.ok, true, `${action}: the verdict is ok`);
|
||||
}
|
||||
} finally {
|
||||
sse.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('times out into browser_timeout when the overlay never answers, and a late result reports delivered:false', async () => {
|
||||
const sse = await openSseClient(server);
|
||||
try {
|
||||
await sse.next((m) => m.type === 'connected');
|
||||
const held = postJson(server, '/agent-target', {
|
||||
token: server.token, selector: 'h1', action: 'quieter', count: 2,
|
||||
});
|
||||
const pushed = await sse.next((m) => m.type === 'agent_target');
|
||||
const verdict = await (await held).json();
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.equal(verdict.error, 'browser_timeout');
|
||||
assert.equal(verdict.timeoutMs, 400);
|
||||
|
||||
const late = await postJson(server, '/agent-target-result', {
|
||||
token: server.token, targetId: pushed.targetId, ok: true,
|
||||
});
|
||||
assert.deepEqual(await late.json(), { ok: true, delivered: false });
|
||||
} finally {
|
||||
sse.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an agent-target result without a targetId', async () => {
|
||||
const res = await postJson(server, '/agent-target-result', {
|
||||
token: server.token, ok: true,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.match(body.error, /missing targetId/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-generate CLI --wait-for-browser', () => {
|
||||
let tmp;
|
||||
let server;
|
||||
|
||||
before(async () => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-wait-'));
|
||||
mkdirSync(join(tmp, '.impeccable/live'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'index.html'), '<html><body><h1>t</h1></body></html>');
|
||||
server = await startServer(8496, {
|
||||
cwd: tmp,
|
||||
env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '400' },
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopServer(server);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runCli(cwd, args) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return { code: 0, json: JSON.parse(stdout) };
|
||||
} catch (err) {
|
||||
return { code: err.status, json: JSON.parse(err.stdout) };
|
||||
}
|
||||
}
|
||||
|
||||
it('rejects a malformed wait budget locally', () => {
|
||||
const { code, json } = runCli(tmp, ['--selector', 'h1', '--wait-for-browser', 'soon']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'invalid_wait');
|
||||
});
|
||||
|
||||
it('gives up with no_browser_connected after the wait budget with no page attached', () => {
|
||||
const startedAt = Date.now();
|
||||
const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '1500']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'no_browser_connected');
|
||||
assert.equal(json.waitedMs, 1500);
|
||||
assert.ok(Date.now() - startedAt >= 1400, 'the CLI actually waited out the budget');
|
||||
});
|
||||
|
||||
it('proceeds to target as soon as a page connects during the wait', async () => {
|
||||
// Connect the fake overlay 1.2s into the CLI's wait window. The CLI must
|
||||
// then send the target; the silent overlay lets it resolve as
|
||||
// browser_timeout, which proves the wait detected the connection and the
|
||||
// request went out (no_browser_connected would mean it never did). The
|
||||
// child runs async: execFileSync would block this process's event loop
|
||||
// and the delayed connect would never happen.
|
||||
const child = new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[GENERATE_SCRIPT, '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'],
|
||||
{ cwd: tmp, encoding: 'utf-8' },
|
||||
(err, stdout) => resolve({ code: err ? err.code : 0, stdout }),
|
||||
);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const sse = await openSseClient(server);
|
||||
const res = await child;
|
||||
sse.close();
|
||||
assert.equal(res.code, 1);
|
||||
const json = JSON.parse(res.stdout);
|
||||
assert.equal(json.error, 'browser_timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-generate CLI local failure modes', () => {
|
||||
function runCli(cwd, args) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return { code: 0, json: JSON.parse(stdout) };
|
||||
} catch (err) {
|
||||
return { code: err.status, json: JSON.parse(err.stdout) };
|
||||
}
|
||||
}
|
||||
|
||||
it('fails with server_not_running when no live server is recorded', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-'));
|
||||
try {
|
||||
const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'server_not_running');
|
||||
assert.match(json._instructions, /live\.mjs/);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with invalid_action locally, listing the vocabulary and the mapping hint', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-'));
|
||||
try {
|
||||
const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bold']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'invalid_action');
|
||||
assert.ok(json.validActions.includes('bolder'));
|
||||
assert.match(json._instructions, /bold -> bolder/);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with selector_required when --selector is missing', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-'));
|
||||
try {
|
||||
const { code, json } = runCli(tmp, ['--action', 'bolder']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'selector_required');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with invalid_count on a non-integer count', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-'));
|
||||
try {
|
||||
const { code, json } = runCli(tmp, ['--selector', 'h1', '--count', 'many']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'invalid_count');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -808,8 +808,8 @@ describe('live-browser source contracts', () => {
|
||||
);
|
||||
assert.equal(
|
||||
SOURCE.match(/beginNewLiveConfiguration\(\);/g)?.length || 0,
|
||||
3,
|
||||
'mouse replace, mouse insert, and keyboard configuration must all supersede older recovery timers',
|
||||
4,
|
||||
'mouse replace, mouse insert, keyboard configuration, and the agent-target entry must all supersede older recovery timers',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -823,6 +823,42 @@ describe('live-browser source contracts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('re-claims busy-declined agent targets only while the overlay can still serve them', () => {
|
||||
const teardownSource = SOURCE.match(/function teardown\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
const clearAt = teardownSource.indexOf('busyDeclinedTargets.clear();');
|
||||
const idleAt = teardownSource.indexOf("setLiveState('IDLE')");
|
||||
assert.ok(clearAt >= 0 && idleAt > clearAt, 'teardown must drop declined targets before its IDLE transition, or a dead overlay re-claims a lease');
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function hidePendingApplyDock\(\) \{\s*pendingApplyInFlight = false;\s*retryDeclinedAgentTargets\(\);/,
|
||||
'finishing a manual apply must withdraw this tab\'s busy report',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/pendingApplyInFlight = loading === true;\s*if \(!pendingApplyInFlight\) retryDeclinedAgentTargets\(\);/,
|
||||
'clearing the apply flag must withdraw this tab\'s busy report',
|
||||
);
|
||||
const helper = SOURCE.match(/function claimAndActOnAgentTarget\(msg\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
assert.match(helper, /if \(agentTargetOverlayGone\(\)\) return;/, 'a gone overlay must not take a lease it cannot act on');
|
||||
assert.match(helper, /if \(!claim\.pending\) return;/, 'the server, not a timer, ends the rescue loop');
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/\/events\?token=' \+ TOKEN \+ '&clientId=' \+ AGENT_TARGET_CLIENT_ID/,
|
||||
'the SSE connection must carry the overlay id, so a disconnect retires its roll-call word',
|
||||
);
|
||||
assert.match(helper, /setTimeout\(\(\) => claimAndActOnAgentTarget\(msg\), AGENT_TARGET_RESCUE_RETRY_MS\);/, 'a denied claim on a live request retries until the lease lapses');
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/scrollAgentTargetIntoView\(el, \(\) => \{[\s\S]{0,300}?if \(agentTargetOverlayGone\(\)\) return;[\s\S]{0,400}?claimAgentTarget\(msg\.targetId, \{ eligible: true \}\)/,
|
||||
'a tab torn down during the scroll settle must not renew its lease',
|
||||
);
|
||||
assert.equal(
|
||||
(SOURCE.match(/claimAndActOnAgentTarget\(msg\)/g) || []).length,
|
||||
4,
|
||||
'the first claim and the busy-to-idle re-claim must share the rescue path (definition, two call sites, the retry)',
|
||||
);
|
||||
});
|
||||
|
||||
it('never DOMParser-injects JSX source (#454)', () => {
|
||||
const isJsxStart = SOURCE.indexOf('function isJsxSourceFile(');
|
||||
const isJsxEnd = SOURCE.indexOf('function sourceHasSessionWrapper(', isJsxStart);
|
||||
|
||||
@@ -810,6 +810,130 @@ for (const { name, fixture } of fixtures) {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Agent-initiated targeting (the `generate` command). The agent names
|
||||
// the element over the live-generate CLI; the overlay resolves the
|
||||
// selector, scrolls to it, enters the picked state, and fires Go with
|
||||
// no user click. Everything downstream (generate event, variants,
|
||||
// cycling, accept, carbonize) is the standard pipeline, so the second
|
||||
// half of this test reuses the core helpers unchanged.
|
||||
// -----------------------------------------------------------------
|
||||
if (shouldRunScenario('agent-target') && fixture.runtime.agentTargetScenario) {
|
||||
it('agent-initiated target scrolls, picks, and generates without a user click', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const scenario = fixture.runtime.agentTargetScenario;
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
atomicDelayMs,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, appRoot, teardown } = session;
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
|
||||
// Failure contracts first; none of these may start a session.
|
||||
const miss = runLiveGenerate(appRoot, { selector: scenario.missSelector, action: scenario.action });
|
||||
assert.equal(miss.ok, false);
|
||||
assert.equal(miss.error, 'no_match');
|
||||
|
||||
const ambiguous = runLiveGenerate(appRoot, { selector: scenario.ambiguousSelector, action: scenario.action });
|
||||
assert.equal(ambiguous.ok, false);
|
||||
assert.equal(ambiguous.error, 'ambiguous');
|
||||
assert.ok(ambiguous.matchCount >= 2, 'ambiguous reports the match count');
|
||||
assert.ok(
|
||||
Array.isArray(ambiguous.candidates) && ambiguous.candidates.length >= 2,
|
||||
'ambiguous lists candidate descriptors',
|
||||
);
|
||||
|
||||
const dry = runLiveGenerate(appRoot, {
|
||||
selector: scenario.selector,
|
||||
action: scenario.action,
|
||||
'dry-run': true,
|
||||
});
|
||||
assert.equal(dry.ok, true, `dry-run resolved: ${JSON.stringify(dry)}`);
|
||||
assert.equal(dry.dryRun, true);
|
||||
assert.equal(dry.matchCount, 1);
|
||||
|
||||
const noSession = await page.evaluate(() => window.__IMPECCABLE_LIVE_CHROME_CORE__.debugState());
|
||||
assert.equal(noSession.currentSessionId, null, 'failed and dry-run targets start no session');
|
||||
assert.equal(await page.evaluate(() => Math.round(window.scrollY)), 0, 'page has not scrolled yet');
|
||||
|
||||
// The happy path: resolve, scroll, pick, Go.
|
||||
t.diagnostic(`Agent-targeting ${scenario.selector} (${scenario.action} x${scenario.count || 3})`);
|
||||
const res = runLiveGenerate(appRoot, {
|
||||
selector: scenario.selector,
|
||||
action: scenario.action,
|
||||
count: scenario.count || 3,
|
||||
...(scenario.prompt ? { prompt: scenario.prompt } : {}),
|
||||
});
|
||||
assert.equal(res.ok, true, `live-generate succeeded: ${JSON.stringify(res)}`);
|
||||
assert.match(res.sessionId, /^[0-9a-f]{8}$/, 'a session id came back');
|
||||
assert.equal(res.action, scenario.action);
|
||||
|
||||
const scrolled = await page.evaluate(() => Math.round(window.scrollY));
|
||||
assert.ok(
|
||||
scrolled >= (scenario.minScrollY || 100),
|
||||
`browser scrolled to the target (scrollY=${scrolled})`,
|
||||
);
|
||||
const dbg = await page.evaluate(() => window.__IMPECCABLE_LIVE_CHROME_CORE__.debugState());
|
||||
assert.equal(dbg.currentSessionId, res.sessionId, 'overlay session matches the CLI result');
|
||||
|
||||
if (scenario.prompt) {
|
||||
// The configure bar rebuild once discarded the preset prompt, so
|
||||
// pin the regression at the wire: the journaled generate event
|
||||
// must carry the prompt the CLI was given.
|
||||
const journalPath = join(appRoot, '.impeccable/live/sessions', `${res.sessionId}.jsonl`);
|
||||
const journaled = readFileSync(journalPath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
|
||||
const generateEvent = journaled.find((entry) => entry.type === 'generate')?.event;
|
||||
assert.equal(
|
||||
generateEvent?.freeformPrompt,
|
||||
scenario.prompt,
|
||||
'the prompt reached the generate event',
|
||||
);
|
||||
}
|
||||
|
||||
// A second target while the session is mid-flight must refuse.
|
||||
const busy = runLiveGenerate(appRoot, { selector: scenario.selector, action: scenario.action });
|
||||
assert.equal(busy.ok, false);
|
||||
assert.equal(busy.error, 'busy');
|
||||
|
||||
await waitForCyclingRobust(page, 3, { agentMode: 'fake', log: (m) => t.diagnostic(m) });
|
||||
const sourceFile = await locateSessionFile(appRoot);
|
||||
assert.ok(sourceFile, 'the variants wrapper landed in a source file');
|
||||
|
||||
await cycleToVariant(page, 2, 3);
|
||||
t.diagnostic('Accepting variant 2');
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
await waitForBarHidden(page);
|
||||
const final = await waitForSourceClean(sourceFile, 20_000, {});
|
||||
assert.match(
|
||||
final,
|
||||
new RegExp(fixture.runtime.acceptedSourcePattern),
|
||||
'accepted source element survives',
|
||||
);
|
||||
for (const needle of fixture.runtime.assertSourceContains || []) {
|
||||
assert.ok(final.includes(needle), `source still contains ${JSON.stringify(needle)} after accept`);
|
||||
}
|
||||
assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed');
|
||||
assert.doesNotMatch(final, /impeccable-carbonize-start/, 'carbonize block rewritten');
|
||||
|
||||
// The overlay is reusable after accept: a fresh dry-run resolves.
|
||||
const post = runLiveGenerate(appRoot, {
|
||||
selector: scenario.selector,
|
||||
action: scenario.action,
|
||||
'dry-run': true,
|
||||
});
|
||||
assert.equal(post.ok, true, 'a new target resolves after accept');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Failure injection for component previews.
|
||||
// -----------------------------------------------------------------
|
||||
@@ -1363,6 +1487,29 @@ function canRunFakeAgentScenario(t) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the live-generate verb (agent-initiated targeting) against a staged
|
||||
* fixture and parse its JSON verdict. The CLI exits 1 for every ok:false
|
||||
* outcome, so the JSON is read from the thrown error's stdout in that case.
|
||||
*/
|
||||
function runLiveGenerate(appRoot, flags) {
|
||||
const args = [];
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
if (value === true) args.push(`--${key}`);
|
||||
else if (value !== undefined && value !== null) args.push(`--${key}`, String(value));
|
||||
}
|
||||
let stdout;
|
||||
try {
|
||||
stdout = runEngineSync('live-generate', args, { cwd: appRoot });
|
||||
} catch (err) {
|
||||
// The verb exits non-zero on every failure verdict but still prints the
|
||||
// JSON the scenario asserts on.
|
||||
stdout = err.stdout || '';
|
||||
if (!stdout.trim()) throw err;
|
||||
}
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a fixture straight to CYCLING on a component-preview session and hand
|
||||
* back the handles the scenarios need: the manifest, the route source, and the
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { compileProviderBlocks } from '../scripts/lib/utils.js';
|
||||
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
@@ -171,4 +172,14 @@ describe('live reference authoring contract', () => {
|
||||
'real-LLM E2E prompt should not hard-code @scope as the universal CSS contract',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps every live action in the generate reference', () => {
|
||||
// generate.md's Step 1 turns request wording into an action value; a
|
||||
// value the picker offers but the reference never names is a request
|
||||
// the agent cannot route.
|
||||
const generateMd = readFileSync(join(ROOT, 'skill/reference/generate.md'), 'utf-8');
|
||||
for (const action of VISUAL_ACTIONS) {
|
||||
assert.match(generateMd, new RegExp('`' + action + '`'), `generate.md must name \`${action}\``);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,6 +322,9 @@ results remain the completed measurements.
|
||||
| 17 | existing surface; asks whether critique is required before polish | completes read-only advice distinguishing assessment from implementation and explaining critique is optional; reference coverage is diagnostic |
|
||||
| 18 | existing surface; explicitly requests polish followed by a next-command recommendation | loads `polish.md` rather than substituting workflow advice for the requested work |
|
||||
| 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, a real-loader success control, a denied-launcher planning-only case, and a denied-launcher documentation case (PRODUCT.md + index.html, no DESIGN.md) | edits require successful playbook/craft-floor reads and a pre-edit denial warning; planning stays read-only and skips craft-floor; documentation requires successful document.md and source reads before any DESIGN.md write, with the denial disclosed before the first tool call after the denied launcher |
|
||||
| 20 | PRODUCT.md + DESIGN.md + `index.html`; prompt is `/impeccable generate 2 bold variants of the hero heading` | loads `reference/generate.md`, and before any `live.md` read (live.md alone is the misroute) |
|
||||
| 21 | same fixture; prompt is natural language with no command word ("Show me a few quieter versions of the hero heading in the browser so I can pick one.") | infers `reference/generate.md` before any `live.md` read |
|
||||
| 22 | same fixture; prompt is `Make the hero heading bolder.` | does **not** load `reference/generate.md` (a plain refinement stays out of live); which playbook the refinement lands on is existing routing's business, not this guard's |
|
||||
|
||||
## Setup launcher-failure branch (2026-09-06, PR #750)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
prepareWorkspace,
|
||||
@@ -79,6 +80,40 @@ function loadedBeforeImplementationWrite(trace, filename) {
|
||||
return loadIndex >= 0 && (writeIndex < 0 || loadIndex < writeIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `first` was loaded, and loaded before `second` whenever `second`
|
||||
* was loaded at all. generate.md hands off to live.md, so a run that reaches
|
||||
* live.md must have gone through generate.md first; live.md alone is the
|
||||
* misroute.
|
||||
*/
|
||||
function loadedBefore(trace, first, second) {
|
||||
const indexOf = (filename) => {
|
||||
const needle = filename.toLowerCase();
|
||||
return trace.toolCalls.findIndex(({ name, input }) => {
|
||||
if (name === 'read') return input?.path?.toLowerCase().includes(needle);
|
||||
if (name === 'bash') return input?.command?.toLowerCase().includes(needle);
|
||||
return false;
|
||||
});
|
||||
};
|
||||
const firstIndex = indexOf(first);
|
||||
const secondIndex = indexOf(second);
|
||||
return firstIndex >= 0 && (secondIndex < 0 || firstIndex < secondIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* A generate scenario that reaches the boot leaves a detached live helper
|
||||
* behind; stop it (idempotent) before the workspace goes away.
|
||||
*/
|
||||
function stopLiveHelper(workspace) {
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(workspace, '.claude/skills/impeccable/scripts/live-server.mjs'), 'stop'],
|
||||
{ cwd: workspace, stdio: 'ignore', timeout: 10_000 },
|
||||
);
|
||||
} catch { /* nothing was running */ }
|
||||
}
|
||||
|
||||
function executedUpdateCommands(trace) {
|
||||
const executableSegments = trace.bashCommands.flatMap((command) =>
|
||||
command
|
||||
@@ -782,5 +817,86 @@ for (const modelId of resolveModelList()) {
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
it('scenario 20: explicit generate request routes to generate.md', async () => {
|
||||
// "generate N <direction> variants of <element>" is the command's whole
|
||||
// grammar. The route must land on generate.md; bolder.md is the
|
||||
// direction's own playbook and live.md loads it later, so neither
|
||||
// counts as the route.
|
||||
const workspace = prepareWorkspace({
|
||||
files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE, 'DESIGN.md': DESIGN_MD_SAMPLE, 'index.html': MINIMAL_LANDING_HTML },
|
||||
});
|
||||
try {
|
||||
const { trace, text } = await runTurn({
|
||||
workspace,
|
||||
model,
|
||||
userPrompt: '/impeccable generate 2 bold variants of the hero heading',
|
||||
maxSteps: 6,
|
||||
});
|
||||
logTrace('S20', 'generate-explicit', modelId, trace, { textSample: text.slice(0, 400) });
|
||||
assert.ok(
|
||||
loadedBefore(trace, 'generate.md', 'live.md'),
|
||||
`agent should load generate.md for an explicit generate request, before any live.md read.\n` +
|
||||
`Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`,
|
||||
);
|
||||
} finally {
|
||||
stopLiveHelper(workspace);
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
it('scenario 21: natural-language variant request infers generate', async () => {
|
||||
// No command word and no "generate": the intent is carried by
|
||||
// "versions", "in the browser", and "pick one". A model that reads
|
||||
// that as a source-side bolder or quieter edit misroutes.
|
||||
const workspace = prepareWorkspace({
|
||||
files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE, 'DESIGN.md': DESIGN_MD_SAMPLE, 'index.html': MINIMAL_LANDING_HTML },
|
||||
});
|
||||
try {
|
||||
const { trace, text } = await runTurn({
|
||||
workspace,
|
||||
model,
|
||||
userPrompt: 'Show me a few quieter versions of the hero heading in the browser so I can pick one.',
|
||||
maxSteps: 6,
|
||||
});
|
||||
logTrace('S21', 'generate-implicit', modelId, trace, { textSample: text.slice(0, 400) });
|
||||
assert.ok(
|
||||
loadedBefore(trace, 'generate.md', 'live.md'),
|
||||
`agent should infer generate.md from a versions-to-pick-from request, before any live.md read.\n` +
|
||||
`Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`,
|
||||
);
|
||||
} finally {
|
||||
stopLiveHelper(workspace);
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
it('scenario 22: a plain refinement request stays out of generate', async () => {
|
||||
// The inverse guard: "make it bolder" asks for one edit in source, not
|
||||
// for variants to choose from in a browser. Over-triggering generate
|
||||
// here would drag every refinement into a live session. Which playbook
|
||||
// the refinement itself lands on is the existing sub-command routing's
|
||||
// business, not this guard's.
|
||||
const workspace = prepareWorkspace({
|
||||
files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE, 'DESIGN.md': DESIGN_MD_SAMPLE, 'index.html': MINIMAL_LANDING_HTML },
|
||||
});
|
||||
try {
|
||||
const { trace, text } = await runTurn({
|
||||
workspace,
|
||||
model,
|
||||
userPrompt: 'Make the hero heading bolder.',
|
||||
maxSteps: 6,
|
||||
});
|
||||
logTrace('S22', 'refinement-not-generate', modelId, trace, { textSample: text.slice(0, 400) });
|
||||
assert.equal(
|
||||
fileLoaded(trace, 'generate.md'),
|
||||
false,
|
||||
`a plain refinement must not route into generate.md.\n` +
|
||||
`Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user