mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +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
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "impeccable",
|
||||
"metadata": {
|
||||
"description": "Design fluency for AI harnesses. 1 skill, 23 commands, and curated anti-patterns for impeccable frontend design."
|
||||
"description": "Design fluency for AI harnesses. 1 skill, 24 commands, and curated anti-patterns for impeccable frontend design."
|
||||
},
|
||||
"owner": {
|
||||
"name": "Paul Bakaus",
|
||||
@@ -11,7 +11,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "4.3.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "4.3.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
- group: svelte
|
||||
fixtures: vite8-sveltekit-stateful
|
||||
- group: react
|
||||
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain
|
||||
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain,vite8-react-pricing-cards
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
run: bun run test:live-e2e
|
||||
env:
|
||||
IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }}
|
||||
IMPECCABLE_E2E_SCENARIOS: core
|
||||
IMPECCABLE_E2E_SCENARIOS: core,agent-target
|
||||
IMPECCABLE_E2E_TEST_TIMEOUT_MS: 180000
|
||||
IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 120000
|
||||
IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 60000
|
||||
@@ -428,7 +428,7 @@ jobs:
|
||||
- group: react-a
|
||||
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
|
||||
- group: react-b
|
||||
fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain
|
||||
fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain,vite8-react-pricing-cards
|
||||
- group: stateful
|
||||
fixtures: vite8-react-radix-dialog,vite8-react-router-spa,vite8-react-styled-components,vite8-react-tabs
|
||||
- group: styling
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Architecture (v3.0+)
|
||||
|
||||
There is **one** user-invocable skill, `impeccable`, with **23 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`:
|
||||
There is **one** user-invocable skill, `impeccable`, with **24 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`:
|
||||
|
||||
- `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source.
|
||||
- `reference/` — one `<command>.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Impeccable
|
||||
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design.
|
||||
Design guidance for AI coding agents. 1 skill, 24 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design.
|
||||
|
||||
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
|
||||
|
||||
@@ -12,7 +12,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
|
||||
|
||||
Impeccable adds:
|
||||
- **One setup flow.** `/impeccable init` records durable product truth in `PRODUCT.md`, so later commands know the audience, purpose, operating context, constraints, voice, and evidence without confusing those facts with surface-level visual direction.
|
||||
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
|
||||
- **24 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
|
||||
- **61 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
|
||||
## What's Included
|
||||
@@ -33,7 +33,7 @@ Start every new project with:
|
||||
|
||||
`init` inspects the project, asks only for material gaps in durable product truth, and writes `PRODUCT.md`. Visitor mode and visual direction are chosen later for each surface; incumbent or newly built visual systems are recorded separately in `DESIGN.md`.
|
||||
|
||||
### 23 Commands
|
||||
### 24 Commands
|
||||
|
||||
All commands are accessed through `/impeccable`:
|
||||
|
||||
@@ -62,6 +62,7 @@ All commands are accessed through `/impeccable`:
|
||||
| `/impeccable adapt` | Adapt for different devices |
|
||||
| `/impeccable optimize` | Performance improvements |
|
||||
| `/impeccable live` | Visual variant mode: iterate on elements in the browser |
|
||||
| `/impeccable generate` | Generate variants of a named element in the live browser, no manual picking |
|
||||
|
||||
Use `/impeccable pin <command>` to create standalone shortcuts (e.g., `pin audit` creates `/audit`).
|
||||
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ Binary lookup order: `IMPECCABLE_BIN`, the platform package, `~/.impeccable/bin/
|
||||
|
||||
## Part of Impeccable
|
||||
|
||||
This CLI is part of [Impeccable](https://impeccable.style), a cross-provider design skill pack for AI-powered development tools. The full suite includes 23 commands for Claude, Cursor, GitHub Copilot, Gemini, Codex, Hermes Agent, Veto, and more.
|
||||
This CLI is part of [Impeccable](https://impeccable.style), a cross-provider design skill pack for AI-powered development tools. The full suite includes 24 commands for Claude, Cursor, GitHub Copilot, Gemini, Codex, Hermes Agent, Veto, and more.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const SKILL_CATEGORIES = {
|
||||
document: 'system',
|
||||
extract: 'system',
|
||||
live: 'system',
|
||||
generate: 'system',
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ Choose the mode from the requested surface, not the product, and persist it only
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
||||
|
||||
Routing: <!-- rule:skill-routing -->
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
||||
|
||||
Generate is a programmatic entry into live mode: the user names an element, a direction, and a count in one sentence, and you boot the live session, point the browser at the element, and the overlay scrolls to it, selects it, and fires the same Go a user click fires. Everything downstream is the standard live session. Read [live.md](live.md) in full now if you have not this session; this file is the entry ramp into its contract, and from Step 4 on you are inside it, with one deliberate divergence: Step 5 closes the session on its own once the accept lands, instead of staying open the way `live` does.
|
||||
|
||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
||||
|
||||
Three prohibitions cover the known ways this command goes wrong. Each names the tempting move first:
|
||||
|
||||
- The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit.
|
||||
- Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects.
|
||||
- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `live-complete.mjs` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag.
|
||||
|
||||
## Step 1: Parse the request
|
||||
|
||||
Three parts, all from the user's sentence:
|
||||
|
||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
||||
- **bold, bolder, stronger, punchier**: `bolder`
|
||||
- **quiet, calmer, softer, toned down**: `quieter`
|
||||
- **simpler, minimal, stripped**: `distill`
|
||||
- **refined, tightened, polished**: `polish`
|
||||
- **font and type words**: `typeset`
|
||||
- **color words**: `colorize`
|
||||
- **arrangement and spacing words**: `layout`
|
||||
- **device and breakpoint words**: `adapt`
|
||||
- **motion words**: `animate`
|
||||
- **playful words**: `delight`
|
||||
- **rule-breaking words**: `overdrive`
|
||||
- **Nothing fits**: `impeccable`, with the user's wording passed as the prompt.
|
||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
||||
- **The element description** ("the pricing cards", "the hero heading"): Step 3 resolves it to a selector.
|
||||
|
||||
Done when you hold an action from the vocabulary, a count from 1 to 8, and the element description.
|
||||
|
||||
## Step 2: Boot live mode and open the page
|
||||
|
||||
Run the boot exactly as [live.md](live.md)'s Start section describes:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live.mjs
|
||||
```
|
||||
|
||||
**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first.
|
||||
|
||||
Then open the app URL that serves a `pageFiles` entry (never `serverPort`; that is the helper, not the app):
|
||||
|
||||
- **Cursor**: `browser_navigate` to the URL now; do not skip it.
|
||||
- **Any other harness with a browser tool**: open the URL with that tool.
|
||||
- **No browser tool exists in this harness**: tell the user the exact URL to open, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects.
|
||||
|
||||
Done when the boot printed `"ok": true` and a page with the overlay is connected, which Step 3 proves by answering anything other than `no_browser_connected`.
|
||||
|
||||
## Step 3: Target the element
|
||||
|
||||
Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-generate.mjs --selector "section.pricing" --action bolder --count 3
|
||||
```
|
||||
|
||||
Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser <ms>`.
|
||||
|
||||
Every verdict carries `_instructions` with the next move for that exact situation, with real values filled in; follow them over your recollection of this file. Two verdicts deserve naming because their fix sits outside the command:
|
||||
|
||||
- **`no_browser_connected`**: Step 2's page is not actually open; open it yourself, then rerun.
|
||||
- **`ambiguous`**: the candidates are listed in the output; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
||||
|
||||
Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, entered the picked state, and fired Go.
|
||||
|
||||
## Step 4: Generate
|
||||
|
||||
Start the poll loop per your harness policy in [live.md](live.md). The queued event for the returned `sessionId` is a standard `generate` event with the picked element's context and a preflighted scaffold; handle it exactly per live.md's Handle generate, which owns everything from planning to the done reply.
|
||||
|
||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
||||
|
||||
**Publishing variants does not end the session.** Keep servicing the poll; accept, discard, and carbonize cleanup follow live.md unchanged, and the helper server stays up through the accept. Done when live.md's contract marks the event you handled complete and the poll is running again.
|
||||
|
||||
## Step 5: Close the session
|
||||
|
||||
Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `live-complete.mjs` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stopping removes the injected live script, and that removal reloads the page one last time: the user's browser now shows the accepted design with no overlay chrome, still served by their dev server.
|
||||
|
||||
- **The user asks for more variants before you wrapped up**: skip the wrap-up, target the next element through the same session (Step 3, with `--dry-run` first when the selector is uncertain), and wrap up after the last accept.
|
||||
- Restarting the dev server to freshen the page feels like tidying. **Never kill or restart the user's dev server**, including one you started in Step 2. It keeps serving the accepted source after wrap-up; a tab that still looks stale needs one hard refresh, not a new server. A relaunched server also hops to the next free port and strands every open tab on the dead one.
|
||||
|
||||
Done when the helper is stopped, the stop output reported the script tag removed, and the dev site still answers with the accepted design.
|
||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `{{scripts_path}}/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||
"argumentHint": ""
|
||||
},
|
||||
"generate": {
|
||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
||||
"argumentHint": "[count] [direction] variants of [element]"
|
||||
},
|
||||
"adapt": {
|
||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||
|
||||
@@ -2037,6 +2037,7 @@
|
||||
function setLiveState(next) {
|
||||
state = next;
|
||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||
retryDeclinedAgentTargets();
|
||||
syncPageInteractionCursor();
|
||||
// Whether a queued steer is still behind a generation is a function of this
|
||||
// state, so the hint has to move with it, not only with the 5s poll.
|
||||
@@ -4014,6 +4015,7 @@
|
||||
|
||||
function hidePendingApplyDock() {
|
||||
pendingApplyInFlight = false;
|
||||
retryDeclinedAgentTargets();
|
||||
clearStoredManualApplyState();
|
||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||
@@ -4047,6 +4049,7 @@
|
||||
function setPendingApplyLoading(loading, count) {
|
||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||
pendingApplyInFlight = loading === true;
|
||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||
else clearStoredManualApplyState();
|
||||
@@ -7112,6 +7115,262 @@
|
||||
}
|
||||
|
||||
//
|
||||
// ------------------------------------------------------------------
|
||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
||||
// element by CSS selector over POST /agent-target; the server pushes an
|
||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
||||
// scrolls the element into view, enters the same picked state a user
|
||||
// click produces, and fires the normal Go pipeline, so everything
|
||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
||||
// The verdict goes back through POST /agent-target-result, which resolves
|
||||
// the agent's held-open CLI call.
|
||||
|
||||
function postAgentTargetResult(targetId, result) {
|
||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN, targetId, ...result }),
|
||||
}).catch(() => { /* server gone; nothing to report to */ });
|
||||
}
|
||||
|
||||
function describeAgentTargetCandidate(el) {
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAgentTargetElement(msg) {
|
||||
let matched;
|
||||
try {
|
||||
matched = [...document.querySelectorAll(msg.selector)];
|
||||
} catch {
|
||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
||||
}
|
||||
let candidates = matched.filter((el) => pickable(el));
|
||||
if (msg.text) {
|
||||
const needle = String(msg.text).toLowerCase();
|
||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
error: {
|
||||
ok: false,
|
||||
error: 'no_match',
|
||||
selector: msg.selector,
|
||||
matchCount: 0,
|
||||
// How many nodes the raw selector hit before the pickable/text
|
||||
// filters: distinguishes a wrong selector from an unpickable match.
|
||||
rawMatchCount: matched.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Number.isInteger(msg.index)) {
|
||||
const el = candidates[msg.index - 1];
|
||||
if (!el) {
|
||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
||||
}
|
||||
return { el, matchCount: candidates.length };
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
return {
|
||||
error: {
|
||||
ok: false,
|
||||
error: 'ambiguous',
|
||||
selector: msg.selector,
|
||||
matchCount: candidates.length,
|
||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { el: candidates[0], matchCount: 1 };
|
||||
}
|
||||
|
||||
function scrollAgentTargetIntoView(el, done) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
||||
let settled = false;
|
||||
let fallback = null;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
removeEventListener('scrollend', finish, true);
|
||||
if (fallback) clearTimeout(fallback);
|
||||
done();
|
||||
};
|
||||
// scrollend where supported; a timer covers engines without it and the
|
||||
// no-movement case (element already at its final resting position).
|
||||
addEventListener('scrollend', finish, true);
|
||||
fallback = setTimeout(finish, 1200);
|
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// One id per page load: the server keys claims and roll-call reports on
|
||||
// it, and only the tab that holds the lease can renew it.
|
||||
const AGENT_TARGET_CLIENT_ID = id8();
|
||||
|
||||
function claimAgentTarget(targetId, report) {
|
||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
||||
}).then((res) => res.json())
|
||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
||||
.catch(() => ({ granted: false, pending: false }));
|
||||
}
|
||||
|
||||
function agentTargetBusyReason() {
|
||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
||||
// at that moment: the moment it is free again (setLiveState), it claims
|
||||
// each of these as eligible, and the server drops the stale report, so a
|
||||
// busy verdict is never built on a tab that has since gone idle. The
|
||||
// server denies claims for resolved targets, so retries are harmless.
|
||||
const busyDeclinedTargets = new Map();
|
||||
|
||||
function declineAgentTargetBusy(msg, busy) {
|
||||
busyDeclinedTargets.set(msg.targetId, msg);
|
||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
||||
}
|
||||
|
||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
||||
// serve a target and must not even claim one: it would hold the lease for
|
||||
// a request it will never act on.
|
||||
function agentTargetOverlayGone() {
|
||||
return !evtSource;
|
||||
}
|
||||
|
||||
// A denied claimant retries at this cadence, a little over the lease, so
|
||||
// the first retry after a dead holder's lease lapses is granted.
|
||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
||||
|
||||
// Claim the lease and act as the holder. A denied claim means another tab
|
||||
// holds the lease. That holder can die before posting its result (reload,
|
||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
||||
// keeps retrying for as long as the server still holds the request: the
|
||||
// answer's `pending` is the server's word that the request is alive, and
|
||||
// it turns false the moment the request resolved or timed out, so no tab
|
||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
||||
// the roll call instead of taking a lease it cannot use. The first claim
|
||||
// and the busy-to-idle re-claim share this.
|
||||
function claimAndActOnAgentTarget(msg) {
|
||||
if (agentTargetOverlayGone()) return;
|
||||
const busy = agentTargetBusyReason();
|
||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
||||
if (claim.granted) { actOnAgentTarget(msg); return; }
|
||||
if (!claim.pending) return;
|
||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function retryDeclinedAgentTargets() {
|
||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
||||
busyDeclinedTargets.delete(targetId);
|
||||
claimAndActOnAgentTarget(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAgentTarget(msg) {
|
||||
if (!msg || typeof msg.targetId !== 'string') return;
|
||||
const busy = agentTargetBusyReason();
|
||||
if (busy) {
|
||||
// Roll call: a busy tab reports itself and never acts. The server
|
||||
// answers `busy` the moment every connected overlay has reported, so
|
||||
// an idle tab elsewhere is never raced by a timer.
|
||||
declineAgentTargetBusy(msg, busy);
|
||||
return;
|
||||
}
|
||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
||||
// hidden tab yields a short head start so a visible one wins when both
|
||||
// exist, and still serves the request on its own: the user finds the
|
||||
// selection waiting when they return to it.
|
||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
||||
}
|
||||
|
||||
function actOnAgentTarget(msg) {
|
||||
if (agentTargetOverlayGone()) return;
|
||||
const reply = (result) => postAgentTargetResult(msg.targetId, result);
|
||||
const busy = agentTargetBusyReason();
|
||||
if (busy) {
|
||||
// Turned busy between claim and act: report it, which also hands the
|
||||
// lease back so the roll call can complete or a rescuer can claim.
|
||||
declineAgentTargetBusy(msg, busy);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveAgentTargetElement(msg);
|
||||
if (resolved.error) { reply(resolved.error); return; }
|
||||
const el = resolved.el;
|
||||
if (msg.dryRun) {
|
||||
reply({
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
matchCount: resolved.matchCount,
|
||||
element: describeAgentTargetCandidate(el),
|
||||
});
|
||||
return;
|
||||
}
|
||||
scrollAgentTargetIntoView(el, () => {
|
||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
||||
// for a rescuer instead of Go minting a session on a dismantled
|
||||
// overlay.
|
||||
if (agentTargetOverlayGone()) return;
|
||||
// Renew the lease right before the irreversible part: a tab whose
|
||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
||||
// one request never gets two Go presses.
|
||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
||||
if (!renewal.granted) return;
|
||||
// An insert placement left mid-configure gives way, exactly as a
|
||||
// click outside it does in handleClick.
|
||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
||||
// Mirror of the user-click pick entry in handleClick, minus the
|
||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
||||
// goes through beginNewLiveConfiguration like every other pick so
|
||||
// deferred recovery sees a fresh interaction revision.
|
||||
selectedElement = el;
|
||||
beginNewLiveConfiguration();
|
||||
showHighlight(selectedElement);
|
||||
clearAnnotations();
|
||||
showAnnotOverlay(selectedElement);
|
||||
showBar('configure');
|
||||
renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden');
|
||||
startScrollTracking();
|
||||
maybePrefetchPage();
|
||||
maybeWarnConditionalAncestor(selectedElement);
|
||||
// Preset what the agent asked for, then fire the same Go a user press
|
||||
// fires. handleGo reads exactly these inputs.
|
||||
selectedAction = msg.action;
|
||||
selectedCount = msg.count;
|
||||
// updateBarContent rebuilds the configure row and replaces the input
|
||||
// element, so the prompt must be written into the input it creates,
|
||||
// never before (the action-chip click handler does the same dance).
|
||||
updateBarContent('configure');
|
||||
const input = uiGetById(PREFIX + '-input');
|
||||
if (input) input.value = msg.prompt || '';
|
||||
handleGo();
|
||||
if (state === 'GENERATING' && currentSessionId) {
|
||||
reply({
|
||||
ok: true,
|
||||
matchCount: resolved.matchCount,
|
||||
sessionId: currentSessionId,
|
||||
action: msg.action,
|
||||
count: msg.count,
|
||||
element: describeAgentTargetCandidate(el),
|
||||
});
|
||||
} else {
|
||||
reply({ ok: false, error: 'go_failed', state });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// SSE (server→browser) + fetch POST (browser→server)
|
||||
// Zero-dependency replacement for WebSocket.
|
||||
//
|
||||
@@ -7121,7 +7380,7 @@
|
||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||
|
||||
function connectSSE() {
|
||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
||||
|
||||
evtSource.onopen = () => {
|
||||
sseRetries = 0; // reset on successful (re)connect
|
||||
@@ -7146,6 +7405,9 @@
|
||||
case 'agent_polling':
|
||||
syncAgentPollingUi(!!msg.connected);
|
||||
break;
|
||||
case 'agent_target':
|
||||
handleAgentTarget(msg);
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
@@ -11715,6 +11977,9 @@ void main() {
|
||||
|
||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||
function teardown() {
|
||||
// Declined targets die with the overlay: the IDLE transition below must
|
||||
// not re-claim a lease this page can no longer act on.
|
||||
busyDeclinedTargets.clear();
|
||||
stopAgentStatusPoll();
|
||||
hideAgentPollTooltip();
|
||||
if (agentPollTooltipEl) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Agent-initiated element targeting for the `generate` command.
|
||||
*
|
||||
* Asks the live overlay to find an element by CSS selector, scroll to it,
|
||||
* enter the picked state, and fire the normal Go pipeline with the given
|
||||
* action and count. On success the browser starts a standard generate
|
||||
* session; the agent then handles the resulting `generate` event from the
|
||||
* poll loop exactly as live.md describes. Requires a running live helper
|
||||
* server (live.mjs boot) and an open page with the overlay attached.
|
||||
*
|
||||
* Usage:
|
||||
* node <scripts_path>/live-generate.mjs --selector "section.pricing" --action bolder --count 3
|
||||
* node <scripts_path>/live-generate.mjs --selector ".card" --text "Studio" --action impeccable --prompt "warmer"
|
||||
*
|
||||
* Flags:
|
||||
* --selector <css> required; resolved with document.querySelectorAll
|
||||
* --text <snippet> optional; keeps only matches whose textContent contains it
|
||||
* --index <n> optional; 1-based pick among the remaining matches
|
||||
* --action <name> optional; one of the live action vocabulary (default: impeccable)
|
||||
* --count <n> optional; variants to request, 1-8 (default: 3)
|
||||
* --prompt <text> optional; freeform direction, same as typing before Go
|
||||
* --dry-run optional; resolve and report without starting anything
|
||||
* --wait-for-browser <ms> optional; poll the helper until a page with the
|
||||
* overlay connects (or the budget runs out) before
|
||||
* sending the target. For harnesses with no browser
|
||||
* tool: hand the user the URL, run with this flag, and
|
||||
* the command fires as soon as they open the page.
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
import { VISUAL_ACTIONS } from './live/vocabulary.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
enterLiveRoot(process.cwd());
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful print (nodejs/node#56645, issue #573), matching context.mjs.
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function fail(payload) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith('--')) continue;
|
||||
const key = arg.slice(2);
|
||||
if (key === 'dry-run') { args['dry-run'] = true; continue; }
|
||||
const value = argv[i + 1];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
// Pre-fetch validation inside a sync helper: no socket can exist yet,
|
||||
// so a plain synchronous exit is safe here.
|
||||
console.log(JSON.stringify({ ok: false, error: 'missing_flag_value', flag: arg }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
args[key] = value;
|
||||
i += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const selector = (args.selector || '').trim();
|
||||
if (!selector) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'selector_required',
|
||||
_instructions: 'Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text "<visible text>" when the class repeats.',
|
||||
});
|
||||
}
|
||||
|
||||
const action = args.action || 'impeccable';
|
||||
if (!VISUAL_ACTIONS.includes(action)) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'invalid_action',
|
||||
action,
|
||||
validActions: VISUAL_ACTIONS,
|
||||
_instructions: 'Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.',
|
||||
});
|
||||
}
|
||||
|
||||
const count = args.count === undefined ? 3 : Number(args.count);
|
||||
if (!Number.isInteger(count) || count < 1 || count > 8) {
|
||||
await fail({ ok: false, error: 'invalid_count', count: args.count, _instructions: 'Pass --count as an integer from 1 to 8.' });
|
||||
}
|
||||
|
||||
let index;
|
||||
if (args.index !== undefined) {
|
||||
index = Number(args.index);
|
||||
if (!Number.isInteger(index) || index < 1) {
|
||||
await fail({ ok: false, error: 'invalid_index', index: args.index, _instructions: 'Pass --index as a 1-based integer position among the matches.' });
|
||||
}
|
||||
}
|
||||
|
||||
let waitForBrowserMs = 0;
|
||||
if (args['wait-for-browser'] !== undefined) {
|
||||
waitForBrowserMs = Number(args['wait-for-browser']);
|
||||
if (!Number.isInteger(waitForBrowserMs) || waitForBrowserMs < 1) {
|
||||
await fail({ ok: false, error: 'invalid_wait', wait: args['wait-for-browser'], _instructions: 'Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.' });
|
||||
}
|
||||
}
|
||||
|
||||
const found = readLiveServerInfo(process.cwd());
|
||||
if (!found || !found.info || !found.info.port || !found.info.token) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'server_not_running',
|
||||
_instructions: 'No live helper server is recorded for this project. Run the live boot first (node <scripts_path>/live.mjs), open the app URL that serves a pageFiles entry, then rerun this command.',
|
||||
});
|
||||
}
|
||||
|
||||
const { port, token } = found.info;
|
||||
|
||||
const INSTRUCTIONS = {
|
||||
ok: (r) => (r.dryRun
|
||||
? `Dry run only: the selector resolves to one element (${r.element?.tag}${r.element?.id ? '#' + r.element.id : ''}) and no session was started. Rerun without --dry-run to generate.`
|
||||
: `Session ${r.sessionId} started: the browser scrolled to the target and fired Go (action "${r.action}", count ${r.count}). Poll now with live-poll.mjs; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.`),
|
||||
no_browser_connected: () => 'No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.',
|
||||
browser_timeout: () => 'The overlay did not answer in time. The page may be mid-reload: run live-status.mjs to check whether a session started anyway, reload the app page, then rerun this command.',
|
||||
invalid_selector: () => 'The selector is not valid CSS. Fix the selector syntax and rerun.',
|
||||
no_match: (r) => (r.rawMatchCount > 0
|
||||
? `The selector hit ${r.rawMatchCount} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.`
|
||||
: 'The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element\'s visible text.'),
|
||||
ambiguous: (r) => `The selector matched ${r.matchCount} elements. Either target their common container instead, or disambiguate with --text "<visible text>" or --index <1-based position>. The candidates are listed in this output.`,
|
||||
index_out_of_range: (r) => `--index is out of range: only ${r.matchCount} match(es). Use an index from 1 to ${r.matchCount}.`,
|
||||
busy: (r) => `A live session is already mid-flight (browser state ${r.state}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.`,
|
||||
go_failed: (r) => `The overlay could not start generation from the picked state (browser state ${r.state}). Reload the app page and rerun this command.`,
|
||||
server_stopping: () => 'The live helper server is shutting down. Re-run the live boot (live.mjs), reopen the page, then rerun this command.',
|
||||
};
|
||||
|
||||
async function waitForBrowserConnection(budgetMs) {
|
||||
const deadline = Date.now() + budgetMs;
|
||||
for (;;) {
|
||||
let status;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status?token=${token}`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
status = await res.json();
|
||||
} catch (err) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'server_unreachable',
|
||||
detail: err?.message,
|
||||
_instructions: 'The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
|
||||
});
|
||||
}
|
||||
if ((status.connectedClients || 0) > 0) return;
|
||||
if (Date.now() >= deadline) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'no_browser_connected',
|
||||
waitedMs: budgetMs,
|
||||
_instructions: INSTRUCTIONS.no_browser_connected(),
|
||||
});
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (waitForBrowserMs > 0) await waitForBrowserConnection(waitForBrowserMs);
|
||||
const body = {
|
||||
token,
|
||||
selector,
|
||||
action,
|
||||
count,
|
||||
...(args.text ? { text: args.text } : {}),
|
||||
...(index !== undefined ? { index } : {}),
|
||||
...(args.prompt ? { prompt: args.prompt } : {}),
|
||||
...(args['dry-run'] ? { dryRun: true } : {}),
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`http://127.0.0.1:${port}/agent-target`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
// Client-side cap just above the server's 15s hold, so a hung helper
|
||||
// still fails fast.
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
} catch (err) {
|
||||
const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError';
|
||||
await fail({
|
||||
ok: false,
|
||||
error: timedOut ? 'request_timeout' : 'server_unreachable',
|
||||
detail: err?.message,
|
||||
_instructions: timedOut
|
||||
? INSTRUCTIONS.browser_timeout()
|
||||
: 'The recorded live server did not answer; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
|
||||
});
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await res.json();
|
||||
} catch {
|
||||
await fail({ ok: false, error: 'bad_server_response', status: res.status });
|
||||
}
|
||||
if (!res.ok) {
|
||||
await fail({ ok: false, error: result.error || `http_${res.status}`, ...result });
|
||||
}
|
||||
const instructions = INSTRUCTIONS[result.ok ? 'ok' : result.error];
|
||||
const output = {
|
||||
...result,
|
||||
...(instructions ? { _instructions: instructions(result) } : {}),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => fail({ ok: false, error: 'unexpected_failure', detail: err?.message }));
|
||||
@@ -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