mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +03:00
The suite spawned its live servers directly and stopped them in after() hooks only, so a run killed mid-test could leave them behind. It now arms the shared reaper and tracks each child like the other live suites do. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1177 lines
54 KiB
JavaScript
1177 lines
54 KiB
JavaScript
/**
|
|
* Protocol tests for agent-initiated element targeting (the `generate`
|
|
* command), driven against the engine binary: POST /agent-target held-open
|
|
* pairing with POST /agent-target-result, validation, the roll call and its
|
|
* leases, the no-browser and timeout verdicts, and the live-generate verb's
|
|
* local failure modes. Skips cleanly without a binary (tests/lib/engine-bin.mjs).
|
|
*
|
|
* 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 { existsSync, 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 { ENGINE_MISSING_MESSAGE, engineEnv, findEngineBinary } from './lib/engine-bin.mjs';
|
|
import { armLiveServerReaper, trackServerChild } from './lib/live-servers.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 ENGINE_BIN = findEngineBinary();
|
|
// Every live server this file starts dies with it, whichever way it exits.
|
|
armLiveServerReaper();
|
|
|
|
// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs);
|
|
// read it from the Rust source so the matrix below can never drift from what
|
|
// the live server accepts.
|
|
function readVisualActions() {
|
|
const rust = readFileSync(join(REPO_ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8');
|
|
const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/);
|
|
if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs');
|
|
return [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]);
|
|
}
|
|
const VISUAL_ACTIONS = readVisualActions();
|
|
|
|
function liveServerPath(cwd) {
|
|
return join(cwd, '.impeccable/live/server.json');
|
|
}
|
|
|
|
/** Run the live-generate verb; the JSON verdict is on stdout on every exit code. */
|
|
function runGenerate(cwd, args) {
|
|
return execFileSync(ENGINE_BIN, ['live-generate', ...args], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
env: engineEnv(ENGINE_BIN, {}),
|
|
});
|
|
}
|
|
|
|
function startServer(port, { cwd, env = {} } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = trackServerChild(spawn(ENGINE_BIN, ['live-server', '--port=' + port], {
|
|
cwd,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: engineEnv(ENGINE_BIN, { IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env }),
|
|
}));
|
|
let output = '';
|
|
proc.stdout.on('data', (d) => { output += d.toString(); });
|
|
proc.stderr.on('data', (d) => { output += d.toString(); });
|
|
proc.on('error', reject);
|
|
// The server writes server.json on listen; poll for it rather than
|
|
// parsing the banner, so a slow first start still resolves.
|
|
const deadline = Date.now() + 10_000;
|
|
const tick = () => {
|
|
try {
|
|
const info = JSON.parse(readFileSync(liveServerPath(cwd), 'utf-8'));
|
|
if (info.port && info.token) {
|
|
resolve({ proc, port: info.port, token: info.token, cwd });
|
|
return;
|
|
}
|
|
} catch { /* not yet */ }
|
|
if (Date.now() > deadline) {
|
|
reject(new Error('Server start timeout. Output: ' + output));
|
|
return;
|
|
}
|
|
setTimeout(tick, 50);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
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', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
|
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', IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS: '150' },
|
|
});
|
|
});
|
|
|
|
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 claimed = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
|
})).json();
|
|
assert.equal(claimed.granted, true);
|
|
const resultRes = await postJson(server, '/agent-target-result', {
|
|
token: server.token,
|
|
targetId: pushed.targetId,
|
|
clientId: 'tab-a',
|
|
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, clientId: 'tab-a', 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, clientId: 'tab-b', 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, pending] of [['tab-a', true], ['tab-b', false]]) {
|
|
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, pending }, 'a decline says whether the request is still pending');
|
|
}
|
|
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, clientId: 'tab-b', 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, clientId: 'tab-a', 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, clientId: 'tab-b', 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, clientId: 'tab-b', 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('keeps a reconnected overlay\'s lease and word when its old connection closes', async () => {
|
|
// An EventSource reconnect opens a replacement connection under the same
|
|
// page-level clientId before the old one is seen to close. The close
|
|
// must retire nothing while the overlay is still connected.
|
|
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' });
|
|
let tabA2 = null;
|
|
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');
|
|
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);
|
|
tabA2 = await openSseClient(server, { clientId: 'tab-a' });
|
|
await tabA2.next((m) => m.type === 'agent_target');
|
|
tabA.close();
|
|
await sleep(150);
|
|
const rival = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true,
|
|
})).json();
|
|
assert.equal(rival.granted, false, 'the lease survived the reconnect');
|
|
const renew = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
|
})).json();
|
|
assert.equal(renew.granted, true, 'the reconnected overlay still holds it');
|
|
await postJson(server, '/agent-target-result', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
|
});
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.sessionId, 'aabbccdd');
|
|
} finally {
|
|
tabA.close();
|
|
tabB.close();
|
|
if (tabA2) tabA2.close();
|
|
}
|
|
});
|
|
|
|
it('answers the resolution verdict when every idle page declined as unable to resolve', async () => {
|
|
// Two idle tabs on pages that lack the element decline with their
|
|
// resolution verdicts; the request resolves as no_match, not busy.
|
|
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');
|
|
for (const [clientId, raw] of [['tab-a', 0], ['tab-b', 3]]) {
|
|
const report = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'IDLE', reason: 'no_match',
|
|
result: { ok: false, error: 'no_match', selector: 'h1', matchCount: 0, rawMatchCount: raw },
|
|
})).json();
|
|
// Every page said no_match: the roll call stays open for the
|
|
// resolution grace (150ms here), so both declines are answered pending.
|
|
assert.deepEqual(report, { ok: true, granted: false, pending: true });
|
|
}
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.error, 'no_match');
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.targetId, pushed.targetId);
|
|
const elapsed = Date.now() - startedAt;
|
|
assert.ok(elapsed >= 140 && elapsed < 380, `answered when the grace lapsed, not before and not by the timeout (${elapsed}ms)`);
|
|
} finally {
|
|
tabA.close();
|
|
tabB.close();
|
|
}
|
|
});
|
|
|
|
it('lets a page that declined as unresolvable claim once its element mounts, within the grace', async () => {
|
|
const tab = await openSseClient(server, { clientId: 'tab-a' });
|
|
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 declined = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'IDLE', reason: 'no_match',
|
|
result: { ok: false, error: 'no_match', matchCount: 0, rawMatchCount: 0 },
|
|
})).json();
|
|
assert.deepEqual(declined, { ok: true, granted: false, pending: true }, 'the only page declining leaves the request pending for the grace');
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
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 }, 'the late mount is served');
|
|
await postJson(server, '/agent-target-result', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
|
});
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.sessionId, 'aabbccdd');
|
|
} finally {
|
|
tab.close();
|
|
}
|
|
});
|
|
|
|
it('extends the grace on a late overlay\'s first no_match word, so it still gets its watch', async () => {
|
|
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 tabA.next((m) => m.type === 'agent_target');
|
|
const decline = (clientId) => postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'IDLE', reason: 'no_match',
|
|
result: { ok: false, error: 'no_match', matchCount: 0, rawMatchCount: 0 },
|
|
});
|
|
assert.equal((await (await decline('tab-a')).json()).pending, true);
|
|
// Tab A's grace (150ms) lapses before tab B says its first word.
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
const late = await (await decline('tab-b')).json();
|
|
assert.equal(late.pending, true, 'a late overlay\'s first no_match word extends the grace');
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
const 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 late overlay\'s watcher claims within its grace');
|
|
await postJson(server, '/agent-target-result', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, matchCount: 1, sessionId: 'aabbccdd',
|
|
});
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.sessionId, 'aabbccdd');
|
|
} finally {
|
|
tabA.close();
|
|
tabB.close();
|
|
}
|
|
});
|
|
|
|
it('resolves the request from the generate event that serves it, so a page that dies before its result cannot leave it pending', async () => {
|
|
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 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.equal(claim.granted, true);
|
|
const result = { ok: true, matchCount: 1, sessionId: 'aabbccdd', action: 'bolder', count: 3, element: { tag: 'h1' } };
|
|
const ack = await postJson(server, '/events', {
|
|
token: server.token, type: 'generate', id: 'aabbccdd', action: 'bolder', count: 3, pageUrl: '/',
|
|
element: { tagName: 'h1', outerHTML: '<h1>Hero</h1>' },
|
|
agentTarget: { targetId: pushed.targetId, clientId: 'tab-a', result },
|
|
});
|
|
assert.equal(ack.status, 200);
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.sessionId, 'aabbccdd');
|
|
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 }, 'nothing is left for a rescuer to serve twice');
|
|
const journal = readFileSync(join(tmp, '.impeccable/live/sessions/aabbccdd.jsonl'), 'utf-8');
|
|
assert.ok(!journal.includes('agentTarget'), 'the envelope never reaches the journal');
|
|
const journaled = JSON.parse(journal.split('\n').find((l) => l.includes('"generate"')));
|
|
assert.equal((journaled.event || journaled).origin, 'agent', 'an agent-initiated generate is marked so the poll hands out the fast path');
|
|
} finally {
|
|
tabA.close();
|
|
tabB.close();
|
|
}
|
|
});
|
|
|
|
it('refuses a generate event for a target another session already answered, and welcomes that session\'s own', async () => {
|
|
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
|
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');
|
|
const claim = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
|
})).json();
|
|
assert.equal(claim.granted, true);
|
|
await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, sessionId: 'cccccccc' });
|
|
assert.equal((await (await held).json()).sessionId, 'cccccccc');
|
|
const event = (id, clientId) => postJson(server, '/events', {
|
|
token: server.token, type: 'generate', id, action: 'bolder', count: 3, pageUrl: '/',
|
|
element: { tagName: 'h1', outerHTML: '<h1>Hero</h1>' },
|
|
agentTarget: { targetId: pushed.targetId, clientId, result: { ok: true, matchCount: 1, sessionId: id, action: 'bolder', count: 3 } },
|
|
});
|
|
// A superseded Go from another page: refused, naming the serving session, nothing journaled.
|
|
const refused = await event('dddddddd', 'tab-b');
|
|
assert.equal(refused.status, 409);
|
|
const body = await refused.json();
|
|
assert.equal(body.error, 'agent_target_already_served');
|
|
assert.equal(body.sessionId, 'cccccccc');
|
|
assert.ok(!existsSync(join(tmp, '.impeccable/live/sessions/dddddddd.jsonl')), 'a refused Go journals nothing');
|
|
// The answering session's own event is welcome.
|
|
assert.equal((await event('cccccccc', 'tab-a')).status, 200);
|
|
} finally {
|
|
tabA.close();
|
|
}
|
|
});
|
|
|
|
it('fences a generate event that lands after the request timed out, so no session the agent was never told about starts', async () => {
|
|
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
|
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');
|
|
const claim = await (await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true,
|
|
})).json();
|
|
assert.equal(claim.granted, true);
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.error, 'browser_timeout');
|
|
const late = await postJson(server, '/events', {
|
|
token: server.token, type: 'generate', id: 'eeeeeeee', action: 'bolder', count: 3, pageUrl: '/',
|
|
element: { tagName: 'h1', outerHTML: '<h1>Hero</h1>' },
|
|
agentTarget: { targetId: pushed.targetId, clientId: 'tab-a', result: { ok: true, matchCount: 1, sessionId: 'eeeeeeee', action: 'bolder', count: 3 } },
|
|
});
|
|
assert.equal(late.status, 409);
|
|
const body = await late.json();
|
|
assert.equal(body.error, 'agent_target_already_served');
|
|
assert.equal(body.sessionId, undefined);
|
|
assert.ok(!existsSync(join(tmp, '.impeccable/live/sessions/eeeeeeee.jsonl')), 'a fenced Go journals nothing');
|
|
} finally {
|
|
tabA.close();
|
|
}
|
|
});
|
|
|
|
it('refuses a generate event naming a target the helper never held, so eviction can never reopen a request', async () => {
|
|
const event = (agentTarget) => postJson(server, '/events', {
|
|
token: server.token, type: 'generate', id: 'ffffffff', action: 'bolder', count: 3, pageUrl: '/',
|
|
element: { tagName: 'h1', outerHTML: '<h1>Hero</h1>' },
|
|
...(agentTarget ? { agentTarget } : {}),
|
|
});
|
|
const refused = await event({ targetId: '0badf00d', clientId: 'tab-a', result: { ok: true, sessionId: 'ffffffff' } });
|
|
assert.equal(refused.status, 409);
|
|
assert.equal((await refused.json()).error, 'agent_target_already_served');
|
|
assert.ok(!existsSync(join(tmp, '.impeccable/live/sessions/ffffffff.jsonl')), 'nothing journaled');
|
|
// Without an envelope the same event is an ordinary Go.
|
|
assert.equal((await event(null)).status, 200);
|
|
});
|
|
|
|
it('forwards the hidden-bar request to the overlay, and refuses a non-boolean', async () => {
|
|
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
|
try {
|
|
await tabA.next((m) => m.type === 'connected');
|
|
const held = postJson(server, '/agent-target', {
|
|
token: server.token, selector: 'h1', action: 'bolder', count: 3, hideLiveBar: true,
|
|
});
|
|
assert.equal((await tabA.next((m) => m.type === 'live_bar')).hidden, true, 'every connected tab hears the helper-wide preference first');
|
|
const pushed = await tabA.next((m) => m.type === 'agent_target');
|
|
assert.equal(pushed.hideLiveBar, true, 'the overlay is told to keep the bottom bar out of the way');
|
|
const tabB = await openSseClient(server, { clientId: 'tab-b' });
|
|
try {
|
|
assert.equal((await tabB.next((m) => m.type === 'connected')).hideLiveBar, true, 'a later connection learns it on connect');
|
|
} finally { tabB.close(); }
|
|
const status = await (await fetch(`http://127.0.0.1:${server.port}/status?token=${server.token}`)).json();
|
|
assert.equal(status.hideLiveBar, true, '/status carries it');
|
|
await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, sessionId: 'aabbccdd' });
|
|
await (await held).json();
|
|
const refused = await postJson(server, '/agent-target', {
|
|
token: server.token, selector: 'h1', action: 'bolder', count: 3, hideLiveBar: 'yes',
|
|
});
|
|
assert.equal(refused.status, 400);
|
|
assert.equal((await refused.json()).error, 'agent_target: hideLiveBar must be a boolean');
|
|
} finally {
|
|
tabA.close();
|
|
}
|
|
});
|
|
|
|
it('honors a result only from the lease holder', async () => {
|
|
const tabA = await openSseClient(server, { clientId: 'tab-a' });
|
|
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');
|
|
const claim = await (await postJson(server, '/agent-target-claim', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true })).json();
|
|
assert.equal(claim.granted, true);
|
|
const bystander = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-b', ok: true, sessionId: 'b0b0b0b0' });
|
|
assert.equal(bystander.status, 409, 'a tab that knows the id and the token still cannot answer for the holder');
|
|
assert.equal((await bystander.json()).reason, 'not_holder');
|
|
const anonymous = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, ok: true });
|
|
assert.equal(anonymous.status, 400, 'a result without a client id is malformed');
|
|
const holder = await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, clientId: 'tab-a', ok: true, sessionId: 'aabbccdd' });
|
|
assert.equal(holder.status, 200);
|
|
assert.equal((await (await held).json()).sessionId, 'aabbccdd', 'the holder answers');
|
|
} finally {
|
|
tabA.close();
|
|
}
|
|
});
|
|
|
|
it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => {
|
|
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 tabA.next((m) => m.type === 'agent_target');
|
|
await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: false, state: 'IDLE', reason: 'no_match',
|
|
result: { ok: false, error: 'no_match', matchCount: 0, rawMatchCount: 0 },
|
|
});
|
|
await postJson(server, '/agent-target-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active',
|
|
});
|
|
const verdict = await (await held).json();
|
|
assert.equal(verdict.error, 'busy');
|
|
assert.equal(verdict.reason, 'session_active');
|
|
} 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(
|
|
ENGINE_BIN,
|
|
['live-generate', '--selector', 'h1', '--action', action, '--dry-run'],
|
|
{ cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) },
|
|
(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-claim', {
|
|
token: server.token, targetId: pushed.targetId, clientId: 'tab-vocab', eligible: true,
|
|
});
|
|
await postJson(server, '/agent-target-result', {
|
|
token: server.token,
|
|
targetId: pushed.targetId,
|
|
clientId: 'tab-vocab',
|
|
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, clientId: 'tab-a', 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', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
|
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 = runGenerate(cwd, args);
|
|
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(
|
|
ENGINE_BIN,
|
|
['live-generate', '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'],
|
|
{ cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) },
|
|
(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', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
|
function runCli(cwd, args) {
|
|
try {
|
|
const stdout = runGenerate(cwd, args);
|
|
return { code: 0, json: JSON.parse(stdout) };
|
|
} catch (err) {
|
|
return { code: err.status, json: JSON.parse(err.stdout) };
|
|
}
|
|
}
|
|
|
|
it('accepts --no-live-bar as a boolean flag', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-'));
|
|
try {
|
|
const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder', '--no-live-bar']);
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error, 'server_not_running', 'the flag parses; the verdict is about the missing helper, not the flag');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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\)/, 'names the boot verb');
|
|
} 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 });
|
|
}
|
|
});
|
|
});
|