mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b130f911ef | ||
|
|
61f37ccfb8 | ||
|
|
55b297d7fc | ||
|
|
97dbaad4a4 | ||
|
|
c654acb005 | ||
|
|
c7b67b3832 | ||
|
|
1b194d9751 | ||
|
|
e2ef633b95 | ||
|
|
6cbb7ce8d1 | ||
|
|
529184bbe4 | ||
|
|
fc620b9620 | ||
|
|
79656d1ce8 | ||
|
|
f148496f67 | ||
|
|
331c2f2696 | ||
|
|
60c4fa25db | ||
|
|
917d3afcf2 | ||
|
|
4e381305e1 | ||
|
|
c6ac34b929 |
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* One argv parser for the Live benchmark / judging scripts.
|
||||
*
|
||||
* These scripts had four subtly different hand-rolled parsers, and the gaps
|
||||
* failed silently rather than loudly: a parser without the `argv[i + 1]`
|
||||
* lookahead turned `--iterations 20` into `iterations: true` and benchmarked
|
||||
* the default 5 runs; a parser without kebab→camel mapping turned
|
||||
* `--median-target=0.4` into a key nothing read, so the comparison ran against
|
||||
* the default threshold. Both produce a clean-looking report of the wrong thing.
|
||||
*
|
||||
* Supported forms, per flag:
|
||||
* --flag → true
|
||||
* --flag=value → 'value'
|
||||
* --flag value → 'value' (unless `value` itself starts with `--`)
|
||||
*
|
||||
* Keys are camel-cased, so `--simulated-tail-ms` and `--simulatedTailMs` both
|
||||
* land on `simulatedTailMs`.
|
||||
*/
|
||||
export function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith('--')) continue;
|
||||
const body = arg.slice(2);
|
||||
if (!body) continue;
|
||||
const equals = body.indexOf('=');
|
||||
if (equals !== -1) {
|
||||
out[toCamel(body.slice(0, equals))] = body.slice(equals + 1);
|
||||
continue;
|
||||
}
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
out[toCamel(body)] = next;
|
||||
index += 1;
|
||||
} else {
|
||||
out[toCamel(body)] = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toCamel(value) {
|
||||
return String(value).replace(/-([a-z0-9])/gi, (_, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a boolean flag. `--headed` and `--headed=true` must mean the same thing;
|
||||
* comparing the raw value against `true` silently ignores the second form.
|
||||
*/
|
||||
export function boolFlag(value, fallback = false) {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['', 'true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a positive integer flag, falling back when absent. Throws on a value
|
||||
* that was clearly meant as a number but isn't one, so `--iterations abc`
|
||||
* fails instead of quietly benchmarking the default.
|
||||
*/
|
||||
export function positiveIntFlag(value, fallback) {
|
||||
if (value === undefined || value === true) return fallback;
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== String(value).trim()) {
|
||||
throw new Error(`expected a positive integer, got: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a flag that must be one of a fixed set.
|
||||
*
|
||||
* A silent `x === 'known' ? 'known' : fallback` is the trap this replaces: the
|
||||
* private evals Live runner passes `--agent=codex`, which fell through to the
|
||||
* canned fake agent and produced a clean-looking report of a deterministic stub
|
||||
* labelled as a real harness run. An unrecognized value is a mistake, not a
|
||||
* request for the default.
|
||||
*/
|
||||
export function resolveEnum(value, allowed, fallback, flagName) {
|
||||
if (value === undefined || value === true) return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (allowed.includes(normalized)) return normalized;
|
||||
throw new Error(`${flagName} must be one of ${allowed.join(', ')}; got: ${value}`);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export const SUITES = {
|
||||
runner: 'node',
|
||||
files: [
|
||||
'tests/ci-test-plan.test.mjs',
|
||||
'tests/cli-args.test.mjs',
|
||||
'tests/context.test.mjs',
|
||||
'tests/context-signals.test.mjs',
|
||||
'tests/critique-storage.test.mjs',
|
||||
@@ -134,17 +135,21 @@ export const SUITES = {
|
||||
'tests/live-e2e-steer-agent.test.mjs',
|
||||
'tests/live-e2e/agent-insert.test.mjs',
|
||||
'tests/live-event-validation.test.mjs',
|
||||
'tests/live-generation-preflight.test.mjs',
|
||||
'tests/live-inject.test.mjs',
|
||||
'tests/live-insert.test.mjs',
|
||||
'tests/live-insert-ui.test.mjs',
|
||||
'tests/live-manual-edits-buffer.test.mjs',
|
||||
'tests/live-poll.test.mjs',
|
||||
'tests/live-poll-lanes.test.mjs',
|
||||
'tests/live-poll-stream.test.mjs',
|
||||
'tests/live-recovery-commands.test.mjs',
|
||||
'tests/live-reference.test.mjs',
|
||||
'tests/live-server.test.mjs',
|
||||
'tests/live-session-store.test.mjs',
|
||||
'tests/live-source-lock.test.mjs',
|
||||
'tests/live-target-context.test.mjs',
|
||||
'tests/live-vue-component.test.mjs',
|
||||
'tests/live-wrap.test.mjs',
|
||||
'tests/live-wrap-buffer-aware.test.mjs',
|
||||
],
|
||||
|
||||
+29
-18
@@ -14,21 +14,24 @@ Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node {{scripts_path}}/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free while you generate and publish in it. Do not block the shell.
|
||||
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
|
||||
- **Codex**: run the poll in the **foreground** (blocking shell; not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
|
||||
- **Codex**: run the default one-shot poll in a **yielded foreground exec session**. Do not suffix it with `&`, use `--stream`, or leave Live without an active foreground poll. Handle every event in the main task; after each handler/reply, restart the foreground poll.
|
||||
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
|
||||
|
||||
Generation delivery policy:
|
||||
- **Default (Cursor and other harnesses):** keep the established atomic single-edit delivery. Do not switch a harness to progressive until its poll loop is known not to block on the extra publish calls. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
|
||||
|
||||
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
|
||||
|
||||
## Start
|
||||
@@ -96,14 +99,14 @@ Server restart rule: start `live-server.mjs` again, then poll. Startup requeues
|
||||
|
||||
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`. Requires a non-empty `freeformPrompt` **or** annotations. Screenshot is sent only when annotations exist (same rule as replace). Use `placeholder` dimensions as a soft size hint for net-new content.
|
||||
|
||||
Speed matters; the user is watching a spinner. Minimize tool calls by using the wrap/insert helper and writing all variants in a single edit.
|
||||
Speed matters; the user is watching the selected element. Reuse server preflight metadata when available, minimize discovery calls, and follow the harness-specific delivery policy above.
|
||||
|
||||
### Insert mode branch
|
||||
|
||||
When `event.mode === "insert"`:
|
||||
|
||||
1. Read the screenshot if `event.screenshotPath` is present (annotations only).
|
||||
2. Run the insert helper instead of wrap:
|
||||
2. If `event.scaffold` is present, use it as the insert-helper result and do **not** run the helper again. Otherwise run the insert helper instead of wrap:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --position after \
|
||||
@@ -113,7 +116,7 @@ node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --positi
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -138,6 +141,8 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
```
|
||||
@@ -157,7 +162,9 @@ Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssS
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
For Nuxt/Vue targets, `live-wrap.mjs` returns `previewMode: "vue-component"` with `file` pointing at an app-local generated manifest under `<appDir>/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at real Vue SFC variants, and `sourceFile` pointing at the untouched `.vue` route. Write `v1.vue`, `v2.vue`, … with one root inside `<template>` and variant CSS in `<style scoped>`; keep dynamic text on the `propContract` bindings as `{{ propName }}`. Do **not** rewrite `sourceFile` during generation: Nuxt/Vite compiles and mounts these dev-only modules without invalidating the route. Accept is the only route write and inlines the selected template/CSS under the source lock; Discard deletes the generated session.
|
||||
|
||||
**Params on component-preview paths go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, and both Svelte/Vue previews mount without an HTML variant wrapper. Declare params in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -295,11 +302,13 @@ In **departure mode**, the prompt narrows the lanes you draw from, not the famil
|
||||
|
||||
When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X, the anti-references ban X), the anti-references win; they describe the brand's standing position, the prompt is one moment.
|
||||
|
||||
### 6. Write all variants in a single edit
|
||||
### 6. Deliver variants
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Colocate preview CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and keeps each delivered state internally complete (no FOUC).
|
||||
|
||||
**Atomic default:** write CSS + all variants + parameter manifests in one edit at `insertLine`, preserving the established behavior.
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
@@ -323,7 +332,7 @@ Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporar
|
||||
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
The browser's MutationObserver accepts either delivery shape. On the transactional progressive path it shows arrived variants and pending dots immediately; Accept and Discard are available as soon as one variant exists. Accepting an arrived variant fences the worker before the browser releases the picker, so later publications are rejected.
|
||||
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
@@ -365,7 +374,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
|
||||
|
||||
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On `svelte-component` and `vue-component` paths, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path.
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
@@ -466,15 +475,19 @@ Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already
|
||||
- The accept event includes `pageUrl`; the poll script must forward it to `live-accept.mjs --page-url PAGE_URL` so accept-time cleanup only scrubs staged copy edits for the current page.
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: post-accept cleanup is required, but it must not stall Codex's control lane. See "Required after accept (carbonize)" below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and stderr banner all point at this required follow-up; none are decorative.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false, mode: "error"`: the operation genuinely failed. **Do not hand-edit the file**; the source was not touched and editing it yourself would either double-apply or race whoever holds it.
|
||||
- `error: "source_locked"`: a generation publish holds the file. Run the same `live-accept.mjs` command again; it is idempotent and will succeed once the publisher releases. Do not poll past it.
|
||||
- `error: "accept_receipt_conflict"`: this session already resolved as `priorOperation` (on `priorVariantId` for an accept), so the request contradicts durable truth. Do not edit. Run `live-status.mjs` and tell the user what the session actually resolved to.
|
||||
- anything else: report the error briefly and run `live-status.mjs` before continuing.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
### Required after accept (carbonize)
|
||||
|
||||
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file; all of which accumulate across sessions.
|
||||
|
||||
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
|
||||
Do these five steps synchronously before the next poll. The source lock, generation epoch, and expected-source hash remain the final safety gates against a generator finishing concurrently with Accept.
|
||||
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
|
||||
@@ -482,9 +495,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. Poll again only after that verification.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
|
||||
@@ -104,6 +104,20 @@ export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session IDs become path segments (journals, snapshots, accept receipts,
|
||||
* preview manifests, generated component dirs). They arrive from CLI `--id`
|
||||
* arguments and HTTP payloads, so anything containing a separator or `..` must
|
||||
* be rejected before it reaches path.join, which would happily escape
|
||||
* `.impeccable/live/`. Real IDs are 8 hex chars; the tests use short slugs.
|
||||
*/
|
||||
export function safeSessionId(id) {
|
||||
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
|
||||
throw new Error('invalid session id: ' + id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
+264
-29
@@ -16,15 +16,62 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import { withSourceLockSync } from './live/source-lock.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
findSvelteComponentManifest,
|
||||
inlineSvelteComponentAccept,
|
||||
removeSvelteComponentSession,
|
||||
} from './live/svelte-component.mjs';
|
||||
import {
|
||||
findVueComponentManifest,
|
||||
inlineVueComponentAccept,
|
||||
retireVueComponentSession,
|
||||
} from './live/vue-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
const ACCEPT_LOCK_WAIT_MS = 1_000;
|
||||
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
|
||||
// value arriving over HTTP.
|
||||
const VARIANT_NUM_PATTERN = /^[0-9]{1,3}$/;
|
||||
|
||||
/**
|
||||
* A thrown accept/discard is a real failure, not a manual handoff.
|
||||
*
|
||||
* live/completion.mjs only classifies a result as `error` when it carries
|
||||
* `mode: 'error'`; anything else unhandled falls through to `agent_done` with a
|
||||
* successful ack, and reference/live.md then tells the agent to finish the edit
|
||||
* by hand. That is right for the documented fallback paths and wrong here: a
|
||||
* `source_locked` contention needs a retry (hand-editing races the publisher
|
||||
* holding the lock), and a crash needs surfacing, not a hand-applied guess.
|
||||
*/
|
||||
function operationFailure(err, extra = {}) {
|
||||
return { handled: false, mode: 'error', error: err.message, ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an unhandled preview-path result as a real failure.
|
||||
*
|
||||
* operationFailure only covers results built from a *thrown* error. The accept
|
||||
* implementations also return `{handled: false, error}` for their own checks
|
||||
* (variant missing, template empty, original text ambiguous), and those arrived
|
||||
* without `mode`, so completion.mjs classified them as agent_done and
|
||||
* reference/live.md routed the agent to "read file, find markers, edit".
|
||||
*
|
||||
* That handoff only makes sense for a plain wrapper session, which is the one
|
||||
* shape with markers in the user's source to edit. Component and isolated
|
||||
* artifact previews keep the source clean until Accept, so there is nothing to
|
||||
* hand-edit and an unhandled result is always a failure. `previewMode` is
|
||||
* exactly that discriminator: only the preview branches set it.
|
||||
*/
|
||||
function markPreviewFailure(result) {
|
||||
if (result?.handled === false && !result.mode && result.previewMode) {
|
||||
return { ...result, mode: 'error' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
@@ -63,7 +110,52 @@ Output (JSON):
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
// `id` becomes a path segment (accept receipts, preview manifests, generated
|
||||
// component dirs). Reject separators and traversal here so one check covers
|
||||
// every downstream sink.
|
||||
try { safeSessionId(id); } catch { console.error('Invalid --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
// `variantNum` is interpolated into a RegExp and into the markup written back
|
||||
// to source. The browser and the /events schema both constrain it to digits;
|
||||
// enforce the same here, or `--variant '.*'` matches the `original` block
|
||||
// first and silently accepts the original while reporting success.
|
||||
if (!isDiscard && !VARIANT_NUM_PATTERN.test(variantNum)) {
|
||||
console.error('Invalid --variant');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const requestedOperation = isDiscard ? 'discard' : 'accept';
|
||||
const priorReceipt = readAcceptReceipt(process.cwd(), id);
|
||||
if (priorReceipt) {
|
||||
const sameOperation = priorReceipt.operation === requestedOperation
|
||||
&& (isDiscard || String(priorReceipt.variantId) === String(variantNum));
|
||||
console.log(JSON.stringify(sameOperation
|
||||
? { ...priorReceipt.result, handled: true, alreadyApplied: true }
|
||||
: {
|
||||
// mode: 'error' is what marks this a real failure rather than a manual
|
||||
// handoff. Without it, live/completion.mjs classifies the reply as
|
||||
// agent_done and reference/live.md tells the agent to "read file, find
|
||||
// markers, edit" by hand — which would apply a second, conflicting
|
||||
// accept on top of the one the receipt already recorded.
|
||||
handled: false,
|
||||
mode: 'error',
|
||||
error: 'accept_receipt_conflict',
|
||||
priorOperation: priorReceipt.operation,
|
||||
priorVariantId: priorReceipt.variantId ?? null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const emitResult = (rawResult) => {
|
||||
const result = markPreviewFailure(rawResult);
|
||||
if (result?.handled !== false) {
|
||||
writeAcceptReceipt(process.cwd(), id, {
|
||||
operation: requestedOperation,
|
||||
variantId: isDiscard ? null : String(variantNum),
|
||||
result,
|
||||
});
|
||||
}
|
||||
console.log(JSON.stringify(result));
|
||||
};
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
@@ -74,47 +166,111 @@ Output (JSON):
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
|
||||
const vueComponentManifest = found || svelteComponentManifest ? null : findVueComponentManifest(id, process.cwd());
|
||||
|
||||
if (!found && !svelteComponentManifest) {
|
||||
if (!found && !svelteComponentManifest && !vueComponentManifest) {
|
||||
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (vueComponentManifest) {
|
||||
if (isDiscard) {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
console.log(JSON.stringify({
|
||||
handled: true,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
retireVueComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: vueComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
}));
|
||||
previewMode: 'vue-component',
|
||||
componentDir: vueComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineVueComponentAccept(vueComponentManifest, variantNum, process.cwd()),
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = {
|
||||
handled: false,
|
||||
error: err.message,
|
||||
result = operationFailure(err, {
|
||||
file: vueComponentManifest.sourceFile,
|
||||
sourceFile: vueComponentManifest.sourceFile,
|
||||
previewMode: 'vue-component',
|
||||
componentDir: vueComponentManifest.componentDir,
|
||||
carbonize: false,
|
||||
});
|
||||
}
|
||||
emitResult(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err, {
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
}
|
||||
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
|
||||
emitResult({ handled: result.handled !== false, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,10 +301,25 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (isDiscard) {
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
let result;
|
||||
// handleDiscard takes the source lock, which throws SOURCE_LOCKED under
|
||||
// contention. Without this catch the CLI exits non-zero with empty stdout
|
||||
// and the agent gets no JSON to act on.
|
||||
try {
|
||||
result = handleDiscard(id, lines, targetFile);
|
||||
} catch (err) {
|
||||
emitResult(operationFailure(err, { file: relFile }));
|
||||
return;
|
||||
}
|
||||
emitResult({ handled: true, file: relFile, carbonize: false, ...result });
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
let result;
|
||||
try {
|
||||
result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
} catch (err) {
|
||||
emitResult(operationFailure(err, { file: relFile }));
|
||||
return;
|
||||
}
|
||||
const acceptedOriginalText = result.acceptedOriginalText || '';
|
||||
delete result.acceptedOriginalText;
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
@@ -167,7 +338,7 @@ Output (JSON):
|
||||
// Non-fatal; the buffer stays as-is and the user can discard later.
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
|
||||
emitResult({ handled: true, file: relFile, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +406,14 @@ function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalB
|
||||
// Discard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleDiscard(id, lines, targetFile) {
|
||||
function handleDiscard(id, _lines, targetFile) {
|
||||
return withSourceLockSync(targetFile, 'discard:' + id, () => {
|
||||
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
|
||||
return handleDiscardUnlocked(id, lines, targetFile);
|
||||
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
|
||||
}
|
||||
|
||||
function handleDiscardUnlocked(id, lines, targetFile) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -330,7 +508,24 @@ function reindentContent(contentLines, fromIndent, toIndent) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
|
||||
return withSourceLockSync(targetFile, 'accept:' + id, () => {
|
||||
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
|
||||
return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
|
||||
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
|
||||
}
|
||||
|
||||
function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
|
||||
const built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
|
||||
if (built.handled === false) return built;
|
||||
fs.writeFileSync(targetFile, built.content, 'utf-8');
|
||||
return {
|
||||
carbonize: built.carbonize,
|
||||
acceptedOriginalText: built.acceptedOriginalText,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -375,11 +570,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
...replacement,
|
||||
...lines.slice(replaceRange.end + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
|
||||
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
|
||||
return {
|
||||
content: newLines.join('\n'),
|
||||
carbonize: needsCarbonize,
|
||||
acceptedOriginalText: originalContent.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function readSourceShadowPreviewMeta(content, id) {
|
||||
const escaped = escapeRegExp(id);
|
||||
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
|
||||
@@ -746,6 +944,21 @@ function detectCommentSyntax(filePath) {
|
||||
// File search (find the file containing session markers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `.impeccable` is the critical entry, and it is not cosmetic.
|
||||
*
|
||||
* Progressive publication stages each revision as `.impeccable/live/artifacts/
|
||||
* <id>-r<n>.<source-ext>`, and those artifacts carry the very marker this search
|
||||
* looks for. The walk reaches `.` for any project whose source is not under one
|
||||
* of the privileged dirs above (this repo's own site lives in `site/pages/`), and
|
||||
* dot-directories sort before letters, so the artifact was found *before* the
|
||||
* real file. isGeneratedFile then declined the accept, and the agent fell back to
|
||||
* carbonizing several hundred lines of stylesheet by hand.
|
||||
*
|
||||
* Impeccable's own state directory is never project source. Never search it.
|
||||
*/
|
||||
const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]);
|
||||
|
||||
function findSessionFile(id, cwd) {
|
||||
const marker = 'impeccable-variants-start ' + id;
|
||||
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
|
||||
@@ -786,7 +999,7 @@ function searchDir(dir, query, seen, depth) {
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
|
||||
if (SEARCH_SKIP_DIRS.has(entry.name)) continue;
|
||||
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
|
||||
if (result) return result;
|
||||
}
|
||||
@@ -798,6 +1011,28 @@ function searchDir(dir, query, seen, depth) {
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function acceptReceiptPath(cwd, id) {
|
||||
return path.join(getLiveDir(cwd), 'accept-receipts', `${safeSessionId(id)}.json`);
|
||||
}
|
||||
|
||||
function readAcceptReceipt(cwd, id) {
|
||||
try { return JSON.parse(fs.readFileSync(acceptReceiptPath(cwd, id), 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeAcceptReceipt(cwd, id, receipt) {
|
||||
const file = acceptReceiptPath(cwd, id);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const value = {
|
||||
id,
|
||||
...receipt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
||||
fs.renameSync(temporary, file);
|
||||
return value;
|
||||
}
|
||||
|
||||
function argVal(args, flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
||||
|
||||
+407
-103
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
|
||||
const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
|
||||
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
||||
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
|
||||
@@ -38,6 +40,9 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
'.impeccable/live/artifacts/',
|
||||
'.impeccable/live/accept-receipts/',
|
||||
'.impeccable/live/locks/',
|
||||
'.impeccable/live/cache/',
|
||||
'.impeccable/live/manual-edit-apply-transaction.json',
|
||||
'.impeccable/live/manual-edit-events.jsonl',
|
||||
@@ -46,10 +51,15 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'.impeccable-live.json',
|
||||
'.impeccable-live/',
|
||||
'app/.impeccable-live/',
|
||||
'src/.impeccable-live/',
|
||||
'node_modules/.impeccable-live/',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
'src/lib/impeccable/[0-9a-f]*/',
|
||||
'plugins/impeccable-live.client.ts',
|
||||
'app/plugins/impeccable-live.client.ts',
|
||||
'src/plugins/impeccable-live.client.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -113,6 +123,7 @@ Output (JSON):
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -120,6 +131,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
@@ -145,13 +162,28 @@ Output (JSON):
|
||||
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
|
||||
process.exit(1);
|
||||
}
|
||||
const gitIgnore = ensureLiveGitIgnores(process.cwd());
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'nuxt',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
@@ -175,12 +207,12 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
|
||||
const target = resolveIgnoreTarget(cwd);
|
||||
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
||||
const block = [
|
||||
IGNORE_MARKER_OPEN,
|
||||
...LIVE_IGNORE_PATTERNS,
|
||||
...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
|
||||
IGNORE_MARKER_CLOSE,
|
||||
].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
|
||||
@@ -202,10 +234,119 @@ export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
||||
file: path.relative(cwd, target.path).split(path.sep).join('/'),
|
||||
mode: target.mode,
|
||||
changed: updated !== existing,
|
||||
patterns: [...LIVE_IGNORE_PATTERNS],
|
||||
patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nuxt adapter
|
||||
//
|
||||
// A script element placed in app.vue is compiled as Vue-rendered DOM and is
|
||||
// not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
|
||||
// generated, dev-only, and outside user-authored source: Live creates one
|
||||
// marked .client.ts plugin on start and removes it on stop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function detectNuxtProject(cwd = process.cwd()) {
|
||||
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/.test(entry.name))
|
||||
?.name;
|
||||
if (!configFile) return null;
|
||||
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = '';
|
||||
if (literalSrcDir) {
|
||||
const candidate = literalSrcDir[2]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
|
||||
appDir = normalized === '.' ? '' : normalized;
|
||||
}
|
||||
} else if (
|
||||
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|
||||
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
|
||||
) {
|
||||
appDir = 'app';
|
||||
}
|
||||
|
||||
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
|
||||
return { configFile, appDir, pluginFile };
|
||||
}
|
||||
|
||||
export function buildNuxtPlugin(port) {
|
||||
return `/* ${NUXT_PLUGIN_MARKER} */
|
||||
const liveSrc = 'http://localhost:${port}/live.js';
|
||||
const liveSelector = 'script[data-impeccable-live-nuxt]';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.dev || typeof document === 'undefined') return;
|
||||
|
||||
const expectedSrc = new URL(liveSrc, window.location.href).href;
|
||||
let script = document.querySelector(liveSelector);
|
||||
if (script?.src === expectedSrc) return;
|
||||
script?.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = liveSrc;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveNuxt = '';
|
||||
document.head.appendChild(script);
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
if (script?.isConnected) script.remove();
|
||||
});
|
||||
});
|
||||
/* /${NUXT_PLUGIN_MARKER} */
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
|
||||
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buildNuxtPlugin(port);
|
||||
fs.mkdirSync(path.dirname(absFile), { recursive: true });
|
||||
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
inserted: true,
|
||||
changed: content !== existing,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
if (!fs.existsSync(absFile)) {
|
||||
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
|
||||
}
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
if (!content.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
removed: false,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.unlinkSync(absFile);
|
||||
const pluginDir = path.dirname(absFile);
|
||||
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
|
||||
return { file: project.pluginFile, removed: true };
|
||||
}
|
||||
|
||||
function resolveIgnoreTarget(cwd) {
|
||||
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
||||
if (gitExcludePath) {
|
||||
|
||||
+46
-14
@@ -27,7 +27,7 @@ const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
export const PER_REQUEST_TIMEOUT_MS = 270_000;
|
||||
export const DEFAULT_EVENT_LEASE_MS = 600_000;
|
||||
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup']);
|
||||
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
@@ -38,8 +38,8 @@ function readServerInfo() {
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data, sourceEventType }) {
|
||||
return { token, id, type, message, file, data, sourceEventType };
|
||||
}
|
||||
|
||||
export function manualApplyPollBanner(event = {}) {
|
||||
@@ -152,7 +152,14 @@ export async function waitForEventAck(base, token, eventId, {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
export async function fetchNextEvent(base, token, {
|
||||
totalDeadline,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs = PER_REQUEST_TIMEOUT_MS,
|
||||
leaseMs = DEFAULT_EVENT_LEASE_MS,
|
||||
signal,
|
||||
} = {}) {
|
||||
while (true) {
|
||||
if (totalDeadline && Date.now() >= totalDeadline) {
|
||||
return { type: 'timeout' };
|
||||
@@ -161,8 +168,15 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
const remaining = totalDeadline
|
||||
? totalDeadline - Date.now()
|
||||
: PER_REQUEST_TIMEOUT_MS;
|
||||
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
|
||||
const slice = Math.min(Math.max(remaining, 1000), perRequestTimeoutMs);
|
||||
const query = new URLSearchParams({
|
||||
token,
|
||||
timeout: String(slice),
|
||||
leaseMs: String(leaseMs),
|
||||
});
|
||||
const normalizedTypes = normalizePollTypes(resolveTypes ? await resolveTypes() : types);
|
||||
if (normalizedTypes.length > 0) query.set('types', normalizedTypes.join(','));
|
||||
const res = await fetch(`${base}/poll?${query}`, { signal });
|
||||
|
||||
if (res.status === 401) {
|
||||
const err = new Error('Authentication failed. The server token may have changed.');
|
||||
@@ -202,11 +216,17 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
await completeAcceptHandling(event, base, token);
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function completeAcceptHandling(event, base, token) {
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
sourceEventType: event.type,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
@@ -217,7 +237,6 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -245,9 +264,9 @@ export function printPollEvent(event) {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000, types, resolveTypes, perRequestTimeoutMs } = {}) {
|
||||
const deadline = Date.now() + totalTimeout;
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline, types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -258,11 +277,14 @@ export async function runPollStream(base, token, {
|
||||
ackTimeoutMs = 600_000,
|
||||
ackPollIntervalMs = 400,
|
||||
shouldContinue = () => true,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs,
|
||||
} = {}) {
|
||||
process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
|
||||
|
||||
while (shouldContinue()) {
|
||||
const event = await fetchNextEvent(base, token);
|
||||
const event = await fetchNextEvent(base, token, { types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -322,14 +344,17 @@ Modes:
|
||||
|
||||
Options:
|
||||
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
|
||||
--types=A,B Lease only these event types
|
||||
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
|
||||
--file PATH Attach a source file path to the reply (generate/steer flow)
|
||||
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
|
||||
--help Show this help message
|
||||
|
||||
Harness note:
|
||||
Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
|
||||
--stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
|
||||
Default one-shot mode is the primary contract, including Codex foreground polling.
|
||||
Claude Code may run it as a background task; Cursor uses a background terminal with exit notification.
|
||||
--stream is retained for harnesses with measured, reliable incremental stdout.
|
||||
Do not use --stream on Cursor.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -360,23 +385,30 @@ Harness note:
|
||||
}
|
||||
|
||||
const streamMode = args.includes('--stream');
|
||||
const typesArg = args.find((a) => a.startsWith('--types='));
|
||||
const types = normalizePollTypes(typesArg ? typesArg.slice('--types='.length) : null);
|
||||
const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
|
||||
const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
|
||||
|
||||
try {
|
||||
if (streamMode) {
|
||||
await runPollStream(base, info.token, { ackTimeoutMs });
|
||||
await runPollStream(base, info.token, { ackTimeoutMs, types });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutArg = args.find((a) => a.startsWith('--timeout='));
|
||||
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
|
||||
await runPollOnce(base, info.token, { totalTimeout });
|
||||
await runPollOnce(base, info.token, { totalTimeout, types });
|
||||
} catch (err) {
|
||||
handlePollError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePollTypes(value) {
|
||||
const values = Array.isArray(value) ? value : String(value || '').split(',');
|
||||
return [...new Set(values.map((type) => String(type).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
// Auto-execute when run directly
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
|
||||
|
||||
+300
-30
@@ -29,7 +29,9 @@ import {
|
||||
resolveLiveBrowserScriptParts,
|
||||
} from './live/browser-script-parts.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { runGenerationPreflight } from './live/generation-preflight.mjs';
|
||||
import { validateEvent } from './live/event-validation.mjs';
|
||||
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
|
||||
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
|
||||
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
|
||||
import {
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
removeAllSvelteComponentSessions,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { removeAllVueComponentSessions } from './live/vue-component.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
@@ -63,6 +66,10 @@ const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
// The browser checkpoints for several unrelated reasons (see checkpointPayload
|
||||
// in live-browser.js). Only these two report that variant availability changed,
|
||||
// and only they may drive variant_progress / the *_reviewable phases.
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port detection
|
||||
@@ -156,29 +163,148 @@ function restorePendingEventsFromStore() {
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
for (const entry of state.pendingEvents) {
|
||||
if (entry.leaseUntil && entry.leaseUntil > now) continue;
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
function findAvailablePendingEvent(now = Date.now(), types = null) {
|
||||
return selectAvailablePendingEvent(state.pendingEvents, { now, types });
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
async function leaseEvent(entry, leaseMs) {
|
||||
// Claim the entry before awaiting anything. prepareGenerateEventForLease
|
||||
// yields to the event loop, and selectAvailablePendingEvent only skips
|
||||
// entries whose lease is in the future — an unclaimed entry would be handed
|
||||
// to a second poll in that window and generated twice.
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
await prepareGenerateEventForLease(entry);
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
// Re-stamp so the lease window starts when the agent actually receives the
|
||||
// work, not when scaffolding began.
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
recordGenerateDelivery(entry);
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
function recordGenerateDelivery(entry) {
|
||||
const event = entry?.event;
|
||||
if (!event || event.type !== 'generate' || event.generationReadyAt) return;
|
||||
const at = Date.now();
|
||||
entry.event = { ...event, generationReadyAt: at };
|
||||
state.sessionStore?.appendEvent(entry.event);
|
||||
recordAgentPhase(event.id, 'generation_ready', { at });
|
||||
}
|
||||
|
||||
async function prepareGenerateEventForLease(entry) {
|
||||
const event = entry?.event;
|
||||
if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
|
||||
|
||||
recordAgentPhase(event.id, 'picked_up');
|
||||
recordAgentPhase(event.id, 'scaffolding');
|
||||
const result = await runGenerationPreflight(event, {
|
||||
cwd: process.cwd(),
|
||||
scriptsDir: __dirname,
|
||||
});
|
||||
entry.event = {
|
||||
...event,
|
||||
scaffoldAttempted: true,
|
||||
scaffoldDurationMs: result.durationMs ?? null,
|
||||
...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
|
||||
};
|
||||
state.sessionStore?.appendEvent(entry.event);
|
||||
recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
|
||||
durationMs: result.durationMs ?? null,
|
||||
previewMode: result.scaffold?.previewMode || 'source',
|
||||
});
|
||||
}
|
||||
|
||||
function recordAgentPhase(id, phase, details = {}) {
|
||||
if (!id) return;
|
||||
const event = {
|
||||
type: 'agent_phase',
|
||||
id,
|
||||
phase,
|
||||
at: Date.now(),
|
||||
...details,
|
||||
};
|
||||
state.sessionStore?.appendEvent(event);
|
||||
broadcast(event);
|
||||
}
|
||||
|
||||
function recordGenerationCheckpoint(event) {
|
||||
if (!event?.id || event.type !== 'checkpoint') return;
|
||||
if (generationIsFenced(event.id)) return;
|
||||
// Only checkpoints that report a change in variant availability are
|
||||
// generation progress. The browser also checkpoints for durability on Tune
|
||||
// slider drags, resumes, and anchor recovery; treating those as progress
|
||||
// echoed `variant_progress` straight back to the browser that sent it, which
|
||||
// remounts the component preview mid-drag (reverting the user's live param
|
||||
// edit and detaching the popover's element), and permanently latched the
|
||||
// *_reviewable phases from the wrong trigger, corrupting generation timings.
|
||||
if (!VARIANT_PROGRESS_CHECKPOINT_REASONS.has(event.reason)) return;
|
||||
const arrived = Number(event.arrivedVariants) || 0;
|
||||
const expected = Number(event.expectedVariants) || 0;
|
||||
if (arrived <= 0 || expected <= 0) return;
|
||||
const previewMode = event.previewMode || 'source';
|
||||
const previewFile = event.previewFile || event.file;
|
||||
if (previewFile) {
|
||||
broadcast({
|
||||
type: 'variant_progress',
|
||||
id: event.id,
|
||||
file: previewFile,
|
||||
sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
|
||||
previewFile,
|
||||
previewMode,
|
||||
arrivedVariants: arrived,
|
||||
expectedVariants: expected,
|
||||
publicationKind: event.publicationKind || 'variants',
|
||||
});
|
||||
}
|
||||
const details = {
|
||||
arrivedVariants: arrived,
|
||||
expectedVariants: expected,
|
||||
checkpointReason: event.reason || null,
|
||||
};
|
||||
const at = Date.now();
|
||||
if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
|
||||
recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
|
||||
}
|
||||
if (arrived >= 2 && expected >= 3 && !generationPhaseAlreadyRecorded(event.id, 'second_reviewable')) {
|
||||
recordAgentPhase(event.id, 'second_reviewable', { ...details, at });
|
||||
}
|
||||
if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
|
||||
recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
|
||||
}
|
||||
}
|
||||
|
||||
function generationIsFenced(id) {
|
||||
if (!state.sessionStore || !id) return false;
|
||||
try {
|
||||
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
|
||||
return snapshot?.generationCanceled === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function generationPhaseAlreadyRecorded(id, phase) {
|
||||
if (!state.sessionStore) return false;
|
||||
try {
|
||||
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
|
||||
return !!snapshot?.generationTimings?.[phase];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id, sourceEventType) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
const idx = state.pendingEvents.findIndex((entry) => (
|
||||
entry.event?.id === id
|
||||
&& (!sourceEventType || entry.event?.type === sourceEventType)
|
||||
));
|
||||
if (idx === -1) return false;
|
||||
const acknowledged = state.pendingEvents[idx].event;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
@@ -187,9 +313,39 @@ function acknowledgePendingEvent(id) {
|
||||
return acknowledged;
|
||||
}
|
||||
|
||||
function findPendingEventById(id) {
|
||||
function releasePendingEvent(id, sourceEventType) {
|
||||
const entry = state.pendingEvents.find((item) => (
|
||||
item.event?.id === id
|
||||
&& (!sourceEventType || item.event?.type === sourceEventType)
|
||||
));
|
||||
if (!entry) return null;
|
||||
entry.leaseUntil = 0;
|
||||
scheduleLeaseFlush();
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function retirePendingGeneration(id) {
|
||||
if (!id) return 0;
|
||||
let retired = 0;
|
||||
for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) {
|
||||
const event = state.pendingEvents[index]?.event;
|
||||
if (event?.id !== id || event.type !== 'generate') continue;
|
||||
state.pendingEvents.splice(index, 1);
|
||||
retired += 1;
|
||||
}
|
||||
if (retired > 0) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
function findPendingEventById(id, sourceEventType) {
|
||||
if (!id) return null;
|
||||
const entry = state.pendingEvents.find((item) => item.event?.id === id);
|
||||
const entry = state.pendingEvents.find((item) => (
|
||||
item.event?.id === id
|
||||
&& (!sourceEventType || item.event?.type === sourceEventType)
|
||||
));
|
||||
return entry?.event || null;
|
||||
}
|
||||
|
||||
@@ -198,7 +354,7 @@ function summarizePendingEventForStatus(entry) {
|
||||
const summary = {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leased: isLeased(entry),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
};
|
||||
if (event.type === 'manual_edit_apply') {
|
||||
@@ -224,7 +380,12 @@ function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
arrivedVariants: snapshot.arrivedVariants ?? 0,
|
||||
visibleVariant: snapshot.visibleVariant ?? null,
|
||||
checkpointRevision: snapshot.checkpointRevision ?? 0,
|
||||
browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
|
||||
publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
|
||||
paramValues: snapshot.paramValues || {},
|
||||
generationPhase: snapshot.generationPhase ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,24 +430,46 @@ function scheduleLeaseFlush() {
|
||||
function flushPendingPolls() {
|
||||
let changed = false;
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
let pollIndex = -1;
|
||||
let entry = null;
|
||||
for (let index = 0; index < state.pendingPolls.length; index += 1) {
|
||||
const candidate = findAvailablePendingEvent(Date.now(), state.pendingPolls[index].types);
|
||||
if (!candidate) continue;
|
||||
pollIndex = index;
|
||||
entry = candidate;
|
||||
break;
|
||||
}
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
const [poll] = state.pendingPolls.splice(pollIndex, 1);
|
||||
// leaseEvent is async (it may scaffold source), but it claims the entry
|
||||
// synchronously, so the next loop iteration will not re-select it. Resolve
|
||||
// the poll when the lease settles rather than awaiting here, so one slow
|
||||
// scaffold never delays the other parked polls. On the exceptional failure
|
||||
// path, answer `timeout` so the agent re-polls; the claim stays until the
|
||||
// lease expires, which keeps a deterministic failure from hot-looping.
|
||||
leaseEvent(entry, poll.leaseMs).then(poll.resolve, (error) => {
|
||||
console.error('[live] lease failed for ' + (entry.event?.id || 'unknown') + ': ' + (error?.message || error));
|
||||
poll.resolve({ type: 'timeout' });
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
if (changed) broadcastAgentPollingIfChanged();
|
||||
}
|
||||
|
||||
function isLeased(entry) {
|
||||
return !!(entry?.leaseUntil && entry.leaseUntil > Date.now());
|
||||
}
|
||||
|
||||
function agentPollingConnected() {
|
||||
const now = Date.now();
|
||||
return state.pendingPolls.length > 0
|
||||
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
|
||||
// A leased event only proves that a poll returned once. The foreground task
|
||||
// may have ended immediately afterward, so only an actively waiting poll is
|
||||
// evidence that steering can wake the task right now.
|
||||
return state.pendingPolls.length > 0;
|
||||
}
|
||||
|
||||
function broadcastAgentPollingIfChanged() {
|
||||
@@ -689,6 +872,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
owner: typeof msg.owner === 'string' ? msg.owner : undefined,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
@@ -698,6 +890,10 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type === 'accept' || msg.type === 'discard') {
|
||||
retirePendingGeneration(msg.id);
|
||||
}
|
||||
recordGenerationCheckpoint(msg);
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
@@ -738,6 +934,12 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
// Agent poll endpoints (unchanged from WS version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parsePollTypes(value) {
|
||||
if (!value) return null;
|
||||
const types = String(value).split(',').map((type) => type.trim()).filter(Boolean);
|
||||
return types.length > 0 ? new Set(types) : null;
|
||||
}
|
||||
|
||||
function handlePollGet(req, res, url) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) {
|
||||
@@ -748,13 +950,25 @@ function handlePollGet(req, res, url) {
|
||||
state.lastPollAt = Date.now();
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
const types = parsePollTypes(url.searchParams.get('types'));
|
||||
const available = findAvailablePendingEvent(Date.now(), types);
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
// Do not await inline: leaseEvent may scaffold source, and this handler runs
|
||||
// on the server's only thread. The client can disconnect during that window,
|
||||
// so check the socket before replying.
|
||||
leaseEvent(available, leaseMs).then((event) => {
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}, (error) => {
|
||||
console.error('[live] lease failed for ' + (available.event?.id || 'unknown') + ': ' + (error?.message || error));
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const poll = { resolve, leaseMs, types };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
@@ -783,12 +997,15 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
if (!file || typeof file !== 'string') return { file };
|
||||
const normalized = file.split(path.sep).join('/');
|
||||
const base = { file: normalized };
|
||||
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
|
||||
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
|
||||
const metadataFile = normalized;
|
||||
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
|
||||
if (!metadataFile.includes('node_modules/.impeccable-live/')
|
||||
&& !metadataFile.includes('src/lib/impeccable/')
|
||||
&& !metadataFile.includes('/.impeccable-live/')) return base;
|
||||
|
||||
let full;
|
||||
try {
|
||||
full = path.resolve(process.cwd(), normalized);
|
||||
full = path.resolve(process.cwd(), metadataFile);
|
||||
const rel = path.relative(process.cwd(), full);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
|
||||
} catch {
|
||||
@@ -797,18 +1014,47 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
|
||||
if (!['svelte-component', 'vue-component'].includes(manifest?.previewMode)
|
||||
|| !manifest.sourceFile) return base;
|
||||
return {
|
||||
file: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
previewFile: normalized,
|
||||
previewMode: 'svelte-component',
|
||||
previewMode: manifest.previewMode,
|
||||
};
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
|
||||
const entriesForId = pendingEvents.filter((entry) => entry.event?.id === msg.id);
|
||||
const pendingTypes = new Set(entriesForId.map((entry) => entry.event?.type));
|
||||
if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
|
||||
if (msg.type === 'complete') {
|
||||
if (pendingTypes.has('carbonize_cleanup')) return 'carbonize_cleanup';
|
||||
return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
|
||||
}
|
||||
if (msg.type === 'steer_done') return 'steer';
|
||||
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
|
||||
// New pollers send sourceEventType explicitly; default to generate only for
|
||||
// older callers so a late worker cannot acknowledge a queued Accept.
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
|
||||
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
|
||||
// never sets sourceEventType on it (the poller is a fresh process that cannot
|
||||
// know what it leased). Returning undefined here makes acknowledgePendingEvent
|
||||
// match *any* event for this id: a stale generate worker's failure silently
|
||||
// consumed the user's queued Accept, which was then never delivered to any
|
||||
// agent and left the browser in SAVING forever. Attribute the failure to the
|
||||
// event this agent actually holds a lease on, and otherwise to `generate` —
|
||||
// never to a wildcard. If that generate was already retired by an Accept, the
|
||||
// ack simply finds no match, which is the correct outcome for a stale reply.
|
||||
if (msg.type === 'error') {
|
||||
return entriesForId.find(isLeased)?.event?.type || 'generate';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handlePollPost(req, res) {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
@@ -869,7 +1115,23 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
|
||||
return;
|
||||
}
|
||||
const pendingEventBeforeAck = findPendingEventById(msg.id);
|
||||
const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
|
||||
if (msg.type === 'retry') {
|
||||
const releasedEvent = releasePendingEvent(msg.id, sourceEventType);
|
||||
if (!releasedEvent) {
|
||||
res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: msg.id ? 'unknown_poll_retry_id' : 'missing_poll_retry_id',
|
||||
id: msg.id,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
flushPendingPolls();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, released: true }));
|
||||
return;
|
||||
}
|
||||
const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
|
||||
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
|
||||
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
@@ -879,7 +1141,7 @@ function handlePollPost(req, res) {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
|
||||
const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
|
||||
let skipJournalReply = false;
|
||||
let existingSession = null;
|
||||
if (!acknowledgedEvent && state.sessionStore && msg.id) {
|
||||
@@ -971,6 +1233,11 @@ function cleanupSvelteComponentSessionsBeforeExit() {
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
|
||||
}
|
||||
try {
|
||||
removeAllVueComponentSessions(process.cwd());
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Vue component session cleanup failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyDeferredAcceptsOnStartup() {
|
||||
@@ -1083,7 +1350,10 @@ if (args.includes('--background')) {
|
||||
process.exit(0);
|
||||
}
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
// The detached child is typically listening in 35-45ms. A 200ms polling
|
||||
// floor dominated configured cold Live startup; poll cheaply and return
|
||||
// as soon as the child has written its ready record.
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
}
|
||||
console.error('Timed out waiting for live server to start.');
|
||||
process.exit(1);
|
||||
|
||||
@@ -37,15 +37,19 @@ export async function statusCli() {
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: manualApply
|
||||
? manualApplyResumeHint(manualApply)
|
||||
: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
recoveryHint: recoveryHint({ server, manualApply }),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function recoveryHint({ server, manualApply }) {
|
||||
if (manualApply) return manualApplyResumeHint(manualApply);
|
||||
if (server) {
|
||||
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
|
||||
}
|
||||
return 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.';
|
||||
}
|
||||
|
||||
function findPendingManualApply(server, activeSessions) {
|
||||
const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
|
||||
if (fromServer) return fromServer;
|
||||
|
||||
+64
-17
@@ -20,6 +20,11 @@ import {
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
import {
|
||||
buildVueComponentCssAuthoring,
|
||||
scaffoldVueComponentSession,
|
||||
shouldUseVueComponentInjection,
|
||||
} from './live/vue-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -160,11 +165,29 @@ The agent should insert variant HTML at insertLine.`);
|
||||
if (filtered.length === 1) {
|
||||
match = filtered[0];
|
||||
} else if (filtered.length === 0) {
|
||||
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
|
||||
// browser-side textContent doesn't appear literally in source. Fall
|
||||
// back to first-match rather than refusing — this is the same
|
||||
// behavior unmodified callers see, just preserved.
|
||||
match = candidates[0];
|
||||
const normalizedText = String(text).replace(/\s+/g, ' ').trim();
|
||||
if (normalizedText.length < 8) {
|
||||
// Very short labels cannot disambiguate siblings reliably. Preserve
|
||||
// the legacy behavior for these low-information picker events.
|
||||
match = candidates[0];
|
||||
} else {
|
||||
// Rendered text that is absent from every candidate usually means
|
||||
// the source uses expressions or component props. Picking the first
|
||||
// same-class sibling silently edits the wrong instance (observed on
|
||||
// Astro result cards), so stop and surface every candidate instead.
|
||||
console.error(JSON.stringify({
|
||||
error: 'element_ambiguous',
|
||||
fallback: 'agent-driven',
|
||||
reason: 'rendered_text_not_in_source',
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
candidates: candidates.map((c) => ({
|
||||
startLine: c.startLine + 1,
|
||||
endLine: c.endLine + 1,
|
||||
})),
|
||||
hint: 'Rendered text does not occur in any matching source branch. The element may use dynamic props or expressions; inspect the candidates and wrap the intended instance manually.',
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
|
||||
// rather than pick wrong, and hand the agent the candidate locations
|
||||
@@ -269,6 +292,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const originalIndented = reindentOriginal(' ');
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
|
||||
const useVueComponent = !useSvelteComponent && shouldUseVueComponentInjection(targetFile);
|
||||
const useFrameworkComponent = useSvelteComponent || useVueComponent;
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -288,7 +313,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
// replacement range to include the wrapper's `<div>` open / close lines
|
||||
// so the entire scaffold gets removed cleanly.
|
||||
const wrapperLines = isJsx ? [
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + ' ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
@@ -299,7 +324,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
indent + '</div>',
|
||||
] : [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + ' ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -315,6 +340,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let vueSession = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -334,6 +360,23 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (useVueComponent) {
|
||||
// Nuxt route-module HMR can invalidate the active page while a generated
|
||||
// wrapper is only partially written. Stage real Vue SFCs in an app-local
|
||||
// dev module tree and leave the route untouched until Accept.
|
||||
vueSession = scaffoldVueComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
sourceStartLine: startLine + 1,
|
||||
sourceEndLine: endLine + 1,
|
||||
originalLines,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), vueSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -356,15 +399,19 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
|
||||
|
||||
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
const vueComponentAuthoring = useVueComponent ? buildVueComponentCssAuthoring(count) : null;
|
||||
const componentSession = svelteSession || vueSession;
|
||||
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : useVueComponent ? 'vue-component' : undefined;
|
||||
const previewMode = componentPreviewMode;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: outputRelFile,
|
||||
sourceFile: useSvelteComponent ? relTargetFile : undefined,
|
||||
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
|
||||
componentDir: svelteSession?.componentDir,
|
||||
propContract: svelteSession?.propContract,
|
||||
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
|
||||
startLine: outputStartLine, // 1-indexed for the agent
|
||||
// wrapperLines is an array but one element (the original-content slot)
|
||||
// is a `\n`-joined multi-line string, so the actual file-row count is
|
||||
@@ -374,10 +421,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: outputEndLine, // 1-indexed
|
||||
insertLine, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
|
||||
styleTag: useSvelteComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
|
||||
styleMode: componentPreviewMode || styleMode.mode,
|
||||
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: svelteComponentAuthoring || vueComponentAuthoring || buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
* - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
|
||||
* - Enter the poll loop: `node live-poll.mjs`
|
||||
* - Enter the harness-native poll loop: `node live-poll.mjs`
|
||||
*
|
||||
* Usage:
|
||||
* node live.mjs # Prepare everything, print JSON, exit
|
||||
@@ -40,6 +40,7 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- Prepares the harness-native foreground/background poll loop
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// A preview whose variants live in component modules rather than in the user's
|
||||
// source. These leave no markers in the real file, so a failed accept gives the
|
||||
// agent nothing to hand-edit and must be reported as a failure rather than
|
||||
// reference/live.md's manual-cleanup handoff. Previously only `svelte-component`
|
||||
// was special-cased, so the same failure on a Vue preview read as success.
|
||||
const PREVIEW_MODES_WITHOUT_SOURCE_MARKERS = new Set([
|
||||
'svelte-component',
|
||||
'vue-component',
|
||||
]);
|
||||
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
|
||||
if (eventType === 'accept' && PREVIEW_MODES_WITHOUT_SOURCE_MARKERS.has(acceptResult?.previewMode)) return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,15 @@ export function validateEvent(msg) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'agent_phase':
|
||||
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
|
||||
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
|
||||
return 'agent_phase: missing or malformed phase';
|
||||
}
|
||||
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
|
||||
return 'agent_phase: durationMs must be a non-negative number';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -131,6 +140,12 @@ export function validateEvent(msg) {
|
||||
if (msg.message.length > 4000) return 'steer: message too long';
|
||||
if (msg.pageUrl !== undefined && typeof msg.pageUrl !== 'string') return 'steer: pageUrl must be string';
|
||||
return null;
|
||||
case 'carbonize_cleanup':
|
||||
if (!isValidId(msg.id)) return 'carbonize_cleanup: missing or malformed id';
|
||||
if (!isValidId(msg.sessionId)) return 'carbonize_cleanup: missing or malformed sessionId';
|
||||
if (!msg.file || typeof msg.file !== 'string') return 'carbonize_cleanup: missing file';
|
||||
if (!isValidVariantId(String(msg.variantId))) return 'carbonize_cleanup: missing or malformed variantId';
|
||||
return null;
|
||||
default:
|
||||
return 'Unknown event type: ' + msg.type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
if (!target.elementId && !target.classes) return null;
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffold the source for a generate event before handing it to an agent.
|
||||
*
|
||||
* Async on purpose. This spawns `live-wrap.mjs`, which walks the project's
|
||||
* source tree and can take seconds (measured at ~7.6s on a large repo when the
|
||||
* element is not found, with a 15s ceiling). The live server is single-threaded
|
||||
* and calls this while leasing a poll, so a synchronous spawn froze the whole
|
||||
* server for that entire window: Accept and Discard POSTs, SSE progress
|
||||
* broadcasts, and every other poll stalled behind it.
|
||||
*/
|
||||
export async function runGenerationPreflight(event, {
|
||||
cwd = process.cwd(),
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const { stdout } = await execFileImpl(process.execPath, command.args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
error: compactError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function replaceTarget(event) {
|
||||
return normalizeTarget(event.element || {});
|
||||
}
|
||||
|
||||
function insertTarget(event) {
|
||||
return {
|
||||
...normalizeTarget(event.insert?.anchor || {}),
|
||||
position: event.insert?.position === 'before' ? 'before' : 'after',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTarget(target) {
|
||||
const classes = Array.isArray(target.classes)
|
||||
? target.classes.join(' ')
|
||||
: String(target.classes || '').trim();
|
||||
const text = typeof target.textContent === 'string'
|
||||
? target.textContent.trim().slice(0, 80)
|
||||
: '';
|
||||
return {
|
||||
elementId: target.id || target.elementId || undefined,
|
||||
classes: classes || undefined,
|
||||
tag: target.tagName || target.tag || undefined,
|
||||
text: text || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactError(error) {
|
||||
const stderr = error?.stderr ? String(error.stderr).trim() : '';
|
||||
const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
|
||||
return String(message).slice(0, 500);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function eventPriority(event = {}) {
|
||||
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
|
||||
if (event.type === 'manual_edit_apply' || event.type === 'steer' || event.type === 'carbonize_cleanup') return 1;
|
||||
if (event.type === 'generate') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function selectAvailablePendingEvent(entries, { now = Date.now(), types = null } = {}) {
|
||||
const allowed = types instanceof Set ? types : (Array.isArray(types) ? new Set(types) : null);
|
||||
return entries
|
||||
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
|
||||
.filter((entry) => !allowed || allowed.has(entry.event?.type))
|
||||
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
const GENERATION_FENCED_PHASES = new Set([
|
||||
'accept_requested',
|
||||
'discard_requested',
|
||||
'carbonize_required',
|
||||
'completed',
|
||||
'discarded',
|
||||
]);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
// No snapshot cache on purpose: appendEvent and getSnapshot both rebuild from
|
||||
// the journal so sequence numbers and phase fences never come from a stale
|
||||
// in-memory copy when the publisher/complete helpers append from another
|
||||
// process. A cache written but never read would grow per session for the
|
||||
// lifetime of the server without ever saving a rebuild.
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
@@ -38,7 +40,10 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
// Publisher/complete helpers can append from a separate process while
|
||||
// the server is alive. Rebuild here so sequence numbers and phase
|
||||
// fences never come from a stale in-memory cache.
|
||||
const prior = rebuildSnapshotFromJournal(getReadableJournalPath(normalized.id), normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
@@ -49,7 +54,6 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
@@ -58,7 +62,6 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
@@ -95,11 +98,6 @@ function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
@@ -116,9 +114,17 @@ function baseSnapshot(id) {
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
browserCheckpointRevision: 0,
|
||||
publicationCheckpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
generationPhase: null,
|
||||
generationTimings: {},
|
||||
variantPlan: null,
|
||||
generationCanceled: false,
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
@@ -158,6 +164,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
generationTimings: { ...(snapshot.generationTimings || {}) },
|
||||
variantPlan: snapshot.variantPlan || null,
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
@@ -174,10 +182,42 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
next.variantPlan = null;
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variant_plan':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.variantPlan = event.plan ?? next.variantPlan;
|
||||
}
|
||||
break;
|
||||
case 'detector_waivers':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.detectorWaivers = [
|
||||
...(next.detectorWaivers || []),
|
||||
...(Array.isArray(event.waivers) ? event.waivers : []),
|
||||
];
|
||||
}
|
||||
break;
|
||||
case 'agent_phase':
|
||||
next.generationPhase = event.phase ?? next.generationPhase;
|
||||
if (event.phase) {
|
||||
next.generationTimings[event.phase] = {
|
||||
at: event.at ?? (Date.parse(entry.ts || '') || null),
|
||||
durationMs: event.durationMs ?? null,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
|
||||
&& !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
|
||||
next.diagnostics.push({
|
||||
error: 'late_generation_event_ignored',
|
||||
type: event.type,
|
||||
phase: next.phase,
|
||||
});
|
||||
break;
|
||||
}
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
@@ -194,27 +234,45 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if (COMPLETED_PHASES.has(next.phase)) {
|
||||
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
|
||||
break;
|
||||
}
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
{
|
||||
const revisionDomain = event.revisionDomain === 'publication'
|
||||
|| (event.reason === 'variants_progress' && !event.owner)
|
||||
? 'publication'
|
||||
: 'browser';
|
||||
const revisionField = revisionDomain === 'publication'
|
||||
? 'publicationCheckpointRevision'
|
||||
: 'browserCheckpointRevision';
|
||||
const currentRevision = next[revisionField]
|
||||
?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
|
||||
?? 0;
|
||||
if ((event.revision ?? 0) >= currentRevision) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next[revisionField] = event.revision ?? currentRevision;
|
||||
if (revisionDomain === 'browser') {
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
}
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.generationCanceled = true;
|
||||
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
|
||||
next.cancelReason = 'accept';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
@@ -232,6 +290,12 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'carbonize_cleanup':
|
||||
next.phase = 'carbonize_cleanup_requested';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'steer_done':
|
||||
next.phase = 'steer_done';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
@@ -243,6 +307,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.generationCanceled = true;
|
||||
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
|
||||
next.cancelReason = 'discard';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
@@ -260,6 +327,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
if (next.generationCanceled && event.sourceEventType === 'generate') {
|
||||
next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
|
||||
break;
|
||||
}
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { getLiveDir, isLiveServerPidReachable } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
// Only used to retire a lock whose contents we cannot read (empty or truncated
|
||||
// by a crash mid-write). A readable lock's fate is decided by its owner's
|
||||
// liveness instead, so a slow critical section is never swept.
|
||||
const UNREADABLE_LOCK_STALE_MS = 60_000;
|
||||
|
||||
export function sourceLockPath(file, cwd = process.cwd()) {
|
||||
const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
|
||||
return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
|
||||
}
|
||||
|
||||
export function withSourceLockSync(file, owner, fn, {
|
||||
cwd = process.cwd(),
|
||||
waitMs = 0,
|
||||
retryMs = 5,
|
||||
} = {}) {
|
||||
const lockPath = sourceLockPath(file, cwd);
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
|
||||
// Identifies this acquisition specifically, so release can tell our own lock
|
||||
// from a replacement that some other writer created.
|
||||
const token = randomUUID();
|
||||
let acquired = false;
|
||||
|
||||
while (!acquired) {
|
||||
clearStaleLock(lockPath);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeFileSync(fd, JSON.stringify({
|
||||
owner,
|
||||
token,
|
||||
pid: process.pid,
|
||||
at: Date.now(),
|
||||
file: path.resolve(cwd, file),
|
||||
}) + '\n');
|
||||
acquired = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
if (Date.now() >= deadline) {
|
||||
const locked = new Error('source_locked');
|
||||
locked.code = 'SOURCE_LOCKED';
|
||||
locked.lockPath = lockPath;
|
||||
throw locked;
|
||||
}
|
||||
sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
|
||||
} finally {
|
||||
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
releaseOwnLock(lockPath, token);
|
||||
}
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function readLock(lockPath) {
|
||||
try { return JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the lock only if it is still the one this call created. If a sweeper
|
||||
* judged our lock stale and another writer replaced it, unlinking here would
|
||||
* end *their* critical section and admit a third writer to the same file.
|
||||
*/
|
||||
function releaseOwnLock(lockPath, token) {
|
||||
const held = readLock(lockPath);
|
||||
if (held && held.token !== token) return;
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lock is stale when its owner is gone, not when it is old.
|
||||
*
|
||||
* Age alone cuts both ways: it sweeps a live holder whose critical section
|
||||
* outran the timeout (a suspended laptop, a stopped process), letting two
|
||||
* writers into the same source file, while still making every accept on a
|
||||
* crashed holder's file wait out the full timeout. Asking the OS whether the
|
||||
* recorded pid is alive answers both correctly: a dead owner releases at once,
|
||||
* and a live owner keeps its lock however long it needs.
|
||||
*/
|
||||
function clearStaleLock(lockPath) {
|
||||
const held = readLock(lockPath);
|
||||
if (!held) {
|
||||
// Unreadable: either a crash truncated it, or we caught the brief window
|
||||
// between create and write in a live acquisition. mtime distinguishes them.
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > UNREADABLE_LOCK_STALE_MS) fs.unlinkSync(lockPath);
|
||||
} catch { /* gone already */ }
|
||||
return;
|
||||
}
|
||||
if (typeof held.pid === 'number' && isLiveServerPidReachable(held.pid)) return;
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Nuxt/Vue live-mode component previews.
|
||||
*
|
||||
* Generation writes real Vue SFCs into a generated app-local module tree.
|
||||
* Nuxt/Vite compiles those modules without touching the active route; Accept
|
||||
* is the only operation that writes the user's .vue source.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { safeSessionId } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectNuxtVueProject(cwd = process.cwd()) {
|
||||
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
|
||||
if (!configFile) return null;
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
|
||||
if (srcDirMatch) {
|
||||
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
|
||||
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
|
||||
appDir = candidate === '.' ? '' : candidate;
|
||||
}
|
||||
}
|
||||
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
|
||||
return { configFile, appDir, componentRoot };
|
||||
}
|
||||
|
||||
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
|
||||
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
|
||||
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
|
||||
}
|
||||
|
||||
export function vueComponentSessionDir(id, cwd = process.cwd()) {
|
||||
const project = detectNuxtVueProject(cwd);
|
||||
if (!project) throw new Error('Nuxt project not found');
|
||||
return path.join(cwd, project.componentRoot, safeSessionId(id));
|
||||
}
|
||||
|
||||
export function vueManifestPathForSession(id, cwd = process.cwd()) {
|
||||
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
|
||||
}
|
||||
|
||||
function ensureVueRuntime(cwd = process.cwd()) {
|
||||
const project = detectNuxtVueProject(cwd);
|
||||
if (!project) throw new Error('Nuxt project not found');
|
||||
const rel = `${project.componentRoot}/__runtime.js`;
|
||||
const file = path.join(cwd, rel);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
|
||||
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
|
||||
return nuxtViteFsModulePath(file, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
|
||||
* Keep the manifest path base-agnostic and let the browser prepend the
|
||||
* runtime's actual buildAssetsDir. A page-route URL such as
|
||||
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
|
||||
*/
|
||||
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
|
||||
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
|
||||
const relative = path.relative(cwd, absolute);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error('Nuxt live module must stay inside the project root');
|
||||
}
|
||||
return '/@fs/' + absolute.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
export function extractVueExpressions(markup) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
||||
let match;
|
||||
while ((match = re.exec(String(markup || '')))) {
|
||||
const expr = match[1].trim();
|
||||
if (!expr || seen.has(expr)) continue;
|
||||
seen.add(expr);
|
||||
out.push({ expr, token: match[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildVuePropContract(expressions) {
|
||||
return expressions.map(({ expr, token }, index) => ({
|
||||
prop: derivePropName(expr, index),
|
||||
expr,
|
||||
placeholder: token,
|
||||
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
|
||||
// the inner `{ user.name }` token; preserve its whitespace for the
|
||||
// browser's source-text → rendered-text map.
|
||||
previewToken: token.slice(1, -1),
|
||||
}));
|
||||
}
|
||||
|
||||
function derivePropName(expr, index) {
|
||||
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
|
||||
return tail?.[1] || `prop${index}`;
|
||||
}
|
||||
|
||||
function substituteVueExpressions(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildVueVariantStub(variant, markup, contract) {
|
||||
const props = contract.length > 0
|
||||
? `<script setup>\ndefineProps({\n${contract.map((entry) => ` ${entry.prop}: { default: '' },`).join('\n')}\n});\n</script>\n\n`
|
||||
: '';
|
||||
return `${props}<template>\n${markup.trim()}\n</template>\n\n<style scoped>\n/* Variant ${variant}: add scoped CSS here */\n</style>\n`;
|
||||
}
|
||||
|
||||
export function scaffoldVueComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
originalLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
const runtimeModule = ensureVueRuntime(cwd);
|
||||
const dir = vueComponentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const originalMarkup = originalLines.join('\n');
|
||||
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
|
||||
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
|
||||
const manifest = {
|
||||
id,
|
||||
previewMode: 'vue-component',
|
||||
framework: 'vue',
|
||||
componentExtension: 'vue',
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
count,
|
||||
propContract,
|
||||
originalMarkup,
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
|
||||
runtimeModule,
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
for (let variant = 1; variant <= count; variant++) {
|
||||
const file = path.join(dir, `v${variant}.vue`);
|
||||
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
|
||||
}
|
||||
return {
|
||||
manifest,
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract,
|
||||
};
|
||||
}
|
||||
|
||||
export function findVueComponentManifest(id, cwd = process.cwd()) {
|
||||
let direct;
|
||||
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
|
||||
if (!fs.existsSync(direct)) return null;
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
|
||||
return manifest?.id === id && manifest?.previewMode === 'vue-component'
|
||||
? { ...manifest, manifestPath: direct }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseVueSfc(source) {
|
||||
const text = String(source || '');
|
||||
const template = text.match(/<template\b[^>]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
|
||||
const style = text.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || '';
|
||||
return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] };
|
||||
}
|
||||
|
||||
function restoreVueExpressions(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract || []) {
|
||||
out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) {
|
||||
const sourcePath = resolveInside(cwd, manifest.sourceFile);
|
||||
const componentDir = resolveInside(cwd, manifest.componentDir);
|
||||
const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`);
|
||||
const resultBase = {
|
||||
file: manifest.sourceFile,
|
||||
sourceFile: manifest.sourceFile,
|
||||
previewMode: 'vue-component',
|
||||
componentDir: manifest.componentDir,
|
||||
carbonize: false,
|
||||
};
|
||||
if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) {
|
||||
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
|
||||
}
|
||||
const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8'));
|
||||
if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase };
|
||||
if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) {
|
||||
return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase };
|
||||
}
|
||||
|
||||
const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n');
|
||||
const start = Number(manifest.sourceStartLine) - 1;
|
||||
const end = Number(manifest.sourceEndLine) - 1;
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
|
||||
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
|
||||
}
|
||||
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
|
||||
const mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || '');
|
||||
const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract)
|
||||
.split('\n')
|
||||
.map((line) => line.trim() ? indent + line.trimStart() : '');
|
||||
let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)];
|
||||
const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim()));
|
||||
if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss);
|
||||
fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8');
|
||||
retireVueComponentSession(manifest.id, cwd);
|
||||
return { handled: true, ...resultBase };
|
||||
}
|
||||
|
||||
function appendVueStyle(lines, cssLines) {
|
||||
let close = -1;
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
if (/<\/style\s*>/.test(lines[index])) { close = index; break; }
|
||||
}
|
||||
const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')];
|
||||
if (close < 0) return [...lines, '', '<style scoped>', ...block.slice(1), '</style>'];
|
||||
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
|
||||
}
|
||||
|
||||
function mergeOriginalVueAttrs(markup, originalMarkup) {
|
||||
const variant = matchOpeningTag(markup);
|
||||
const original = matchOpeningTag(originalMarkup);
|
||||
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
|
||||
const variantAttrs = parseStaticAttrs(variant.attrs);
|
||||
const originalAttrs = parseStaticAttrs(original.attrs);
|
||||
const additions = [];
|
||||
let attrs = variant.attrs;
|
||||
|
||||
const originalClass = originalAttrs.get('class');
|
||||
const variantClass = variantAttrs.get('class');
|
||||
if (originalClass && variantClass) {
|
||||
const classes = [
|
||||
...variantClass.value.split(/\s+/),
|
||||
...originalClass.value.split(/\s+/),
|
||||
].filter(Boolean);
|
||||
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
|
||||
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
|
||||
} else if (originalClass) {
|
||||
additions.push(originalClass.raw);
|
||||
}
|
||||
for (const [name, attr] of originalAttrs) {
|
||||
if (name === 'class' || variantAttrs.has(name)) continue;
|
||||
additions.push(attr.raw);
|
||||
}
|
||||
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
|
||||
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
|
||||
}
|
||||
|
||||
function matchOpeningTag(markup) {
|
||||
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
|
||||
return match ? {
|
||||
raw: match[0],
|
||||
tag: match[1],
|
||||
attrs: match[2] || '',
|
||||
close: match[3],
|
||||
index: match.index || 0,
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the attributes of a Vue opening tag.
|
||||
*
|
||||
* The name pattern is deliberately permissive so directive shorthands survive
|
||||
* a round trip: `@click.prevent`, `:aria-label`, `:[dynamicKey]`, `#default`,
|
||||
* and `v-cloak` are all one attribute each. A name-anchored pattern such as
|
||||
* `[A-Za-z_:][\w:.-]*` skips the `@`/`#` sigil and re-matches from the bare
|
||||
* name, which turns `@click="submit"` into a literal `click="submit"` DOM
|
||||
* attribute on Accept. Values are optional so valueless attributes
|
||||
* (`disabled`, `v-cloak`) are recorded rather than dropped.
|
||||
*/
|
||||
function parseStaticAttrs(attrs) {
|
||||
const out = new Map();
|
||||
const re = /([^\s"'=<>/]+)(?:\s*=\s*(?:(["'])([\s\S]*?)\2|([^\s"'=<>`]+)))?/g;
|
||||
let match;
|
||||
while ((match = re.exec(attrs))) {
|
||||
const quoted = match[2] !== undefined;
|
||||
const valueless = !quoted && match[4] === undefined;
|
||||
out.set(normalizeVueAttrName(match[1]), {
|
||||
raw: match[0],
|
||||
value: valueless ? '' : (quoted ? match[3] : match[4]),
|
||||
quote: quoted ? match[2] : '"',
|
||||
valueless,
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse Vue's directive shorthands to their canonical form for identity
|
||||
* comparison only (the raw text is what gets written back). Without this, an
|
||||
* original `:aria-label` and a variant `v-bind:aria-label` read as two
|
||||
* different attributes and Accept emits both, which is a Vue compile error.
|
||||
*/
|
||||
function normalizeVueAttrName(name) {
|
||||
const raw = String(name);
|
||||
if (raw.startsWith('@')) return `v-on:${raw.slice(1)}`;
|
||||
if (raw.startsWith(':')) return `v-bind:${raw.slice(1)}`;
|
||||
if (raw.startsWith('#')) return `v-slot:${raw.slice(1)}`;
|
||||
if (raw.startsWith('.')) return `v-bind:${raw.slice(1)}.prop`;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function removeVueComponentSession(id, cwd = process.cwd()) {
|
||||
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an accepted/discarded session undiscoverable immediately while keeping
|
||||
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
|
||||
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
|
||||
* missing module and emit a console error. The generated directory remains
|
||||
* ignored and removeAllVueComponentSessions removes it on server shutdown.
|
||||
*/
|
||||
export function retireVueComponentSession(id, cwd = process.cwd()) {
|
||||
let dir;
|
||||
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
|
||||
for (const name of ['manifest.json', 'params.json']) {
|
||||
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function removeAllVueComponentSessions(cwd = process.cwd()) {
|
||||
const project = detectNuxtVueProject(cwd);
|
||||
if (!project) return;
|
||||
const root = path.join(cwd, project.componentRoot);
|
||||
if (!fs.existsSync(root)) return;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function buildVueComponentCssAuthoring(count) {
|
||||
return {
|
||||
mode: 'vue-component',
|
||||
count,
|
||||
requirements: [
|
||||
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
|
||||
'Keep one root element inside <template> and put variant CSS in <style scoped>.',
|
||||
'Keep propContract bindings as {{ propName }} instead of snapshot text.',
|
||||
'Do not add data-impeccable-* attributes.',
|
||||
],
|
||||
forbidden: ['Rewriting sourceFile during preview', 'data-impeccable-* attributes', 'Off-brand replacement content'],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInside(cwd, value) {
|
||||
if (!value || path.isAbsolute(value)) return null;
|
||||
const full = path.resolve(cwd, value);
|
||||
const rel = path.relative(cwd, full);
|
||||
return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : full;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for scripts/lib/cli-args.mjs — the shared argv parser for the Live
|
||||
* benchmark / judging scripts.
|
||||
* Run with: node --test tests/cli-args.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum, toCamel } from '../scripts/lib/cli-args.mjs';
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('reads space-separated values', () => {
|
||||
// The regression: without the argv[i+1] lookahead this yielded
|
||||
// {fixture: true, iterations: true}, silently benchmarking the defaults.
|
||||
assert.deepEqual(
|
||||
parseArgs(['--fixture', 'vite8-react-modal', '--iterations', '20']),
|
||||
{ fixture: 'vite8-react-modal', iterations: '20' },
|
||||
);
|
||||
});
|
||||
|
||||
it('reads --flag=value values', () => {
|
||||
assert.deepEqual(parseArgs(['--fixture=vite8-react-plain']), { fixture: 'vite8-react-plain' });
|
||||
});
|
||||
|
||||
it('treats a flag followed by another flag as boolean', () => {
|
||||
assert.deepEqual(parseArgs(['--headed', '--quiet']), { headed: true, quiet: true });
|
||||
});
|
||||
|
||||
it('treats a trailing flag as boolean', () => {
|
||||
assert.deepEqual(parseArgs(['--append']), { append: true });
|
||||
});
|
||||
|
||||
it('camel-cases kebab keys so both spellings land on one key', () => {
|
||||
assert.deepEqual(parseArgs(['--simulated-tail-ms=250']), { simulatedTailMs: '250' });
|
||||
assert.deepEqual(parseArgs(['--simulatedTailMs=250']), { simulatedTailMs: '250' });
|
||||
assert.deepEqual(parseArgs(['--median-target', '0.4']), { medianTarget: '0.4' });
|
||||
});
|
||||
|
||||
it('keeps a value that contains an equals sign intact', () => {
|
||||
assert.deepEqual(parseArgs(['--model=claude-sonnet-4-6=x']), { model: 'claude-sonnet-4-6=x' });
|
||||
});
|
||||
|
||||
it('ignores positional args and a bare --', () => {
|
||||
assert.deepEqual(parseArgs(['positional', '--', '--real', 'v']), { real: 'v' });
|
||||
});
|
||||
|
||||
it('lets a later occurrence win', () => {
|
||||
assert.deepEqual(parseArgs(['--agent', 'fake', '--agent', 'llm']), { agent: 'llm' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCamel', () => {
|
||||
it('upcases after hyphens only', () => {
|
||||
assert.equal(toCamel('simulated-tail-ms'), 'simulatedTailMs');
|
||||
assert.equal(toCamel('p95-target'), 'p95Target');
|
||||
assert.equal(toCamel('already'), 'already');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolFlag', () => {
|
||||
it('accepts the bare-flag sentinel and the explicit spellings alike', () => {
|
||||
// --headed and --headed=true must not diverge.
|
||||
assert.equal(boolFlag(true), true);
|
||||
assert.equal(boolFlag('true'), true);
|
||||
assert.equal(boolFlag('1'), true);
|
||||
assert.equal(boolFlag('yes'), true);
|
||||
assert.equal(boolFlag(''), true);
|
||||
});
|
||||
|
||||
it('recognizes negative spellings', () => {
|
||||
assert.equal(boolFlag('false'), false);
|
||||
assert.equal(boolFlag('0'), false);
|
||||
assert.equal(boolFlag('no'), false);
|
||||
});
|
||||
|
||||
it('falls back when absent or unrecognized', () => {
|
||||
assert.equal(boolFlag(undefined), false);
|
||||
assert.equal(boolFlag(undefined, true), true);
|
||||
assert.equal(boolFlag('maybe', true), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positiveIntFlag', () => {
|
||||
it('parses positive integers', () => {
|
||||
assert.equal(positiveIntFlag('20', 5), 20);
|
||||
});
|
||||
|
||||
it('falls back when absent or given as a bare flag', () => {
|
||||
assert.equal(positiveIntFlag(undefined, 5), 5);
|
||||
assert.equal(positiveIntFlag(true, 5), 5);
|
||||
});
|
||||
|
||||
it('throws rather than silently using the default', () => {
|
||||
// Quietly benchmarking 5 iterations when 20 were asked for is the failure
|
||||
// this replaces.
|
||||
for (const bad of ['abc', '0', '-3', '2.5', '20x']) {
|
||||
assert.throws(() => positiveIntFlag(bad, 5), /positive integer/, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEnum', () => {
|
||||
it('accepts an allowed value, case-insensitively', () => {
|
||||
assert.equal(resolveEnum('llm', ['fake', 'llm'], 'fake', '--agent'), 'llm');
|
||||
assert.equal(resolveEnum('LLM', ['fake', 'llm'], 'fake', '--agent'), 'llm');
|
||||
});
|
||||
|
||||
it('falls back when absent or given as a bare flag', () => {
|
||||
assert.equal(resolveEnum(undefined, ['fake', 'llm'], 'fake', '--agent'), 'fake');
|
||||
assert.equal(resolveEnum(true, ['fake', 'llm'], 'fake', '--agent'), 'fake');
|
||||
});
|
||||
|
||||
it('throws on an unrecognized value instead of silently using the default', () => {
|
||||
// The private evals Live runner passes --agent=codex. Falling back to the
|
||||
// canned fake agent produced a clean report of a deterministic stub labelled
|
||||
// as a real harness run.
|
||||
assert.throws(
|
||||
() => resolveEnum('codex', ['fake', 'llm'], 'fake', '--agent'),
|
||||
/--agent must be one of fake, llm; got: codex/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveEnum('progresive', ['atomic', 'progressive'], 'atomic', '--delivery'),
|
||||
/--delivery must be one of atomic, progressive/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,9 @@ for (const name of listFixtures()) {
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/sessions/example.jsonl',
|
||||
'.impeccable/live/previews/example/v1.html',
|
||||
'.impeccable/live/artifacts/example-r1.jsx',
|
||||
'.impeccable/live/accept-receipts/example.json',
|
||||
'.impeccable/live/locks/example.lock',
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
@@ -127,6 +130,9 @@ for (const name of listFixtures()) {
|
||||
assert.match(ignored, /\.impeccable\/live\/server\.json/);
|
||||
assert.match(ignored, /\.impeccable\/live\/sessions\/example\.jsonl/);
|
||||
assert.match(ignored, /\.impeccable\/live\/previews\/example\/v1\.html/);
|
||||
assert.match(ignored, /\.impeccable\/live\/artifacts\/example-r1\.jsx/);
|
||||
assert.match(ignored, /\.impeccable\/live\/accept-receipts\/example\.json/);
|
||||
assert.match(ignored, /\.impeccable\/live\/locks\/example\.lock/);
|
||||
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
|
||||
assert.match(ignored, /src\/lib\/impeccable\/ImpeccableLiveRoot\.svelte/);
|
||||
assert.match(ignored, /src\/lib\/impeccable\/__runtime\.js/);
|
||||
@@ -142,6 +148,15 @@ for (const name of listFixtures()) {
|
||||
assert.match(root, /localhost:9999\/live\.js/, 'SvelteKit root component loads live.js');
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'nuxt') {
|
||||
const plugin = result.results[0];
|
||||
const body = readFileSync(join(tmp, plugin.file), 'utf-8');
|
||||
assert.equal(plugin.inserted, true, 'Nuxt client plugin was created');
|
||||
assert.match(body, /impeccable-live-nuxt-plugin/);
|
||||
assert.match(body, /if \(!import\.meta\.dev/);
|
||||
assert.match(body, /localhost:9999\/live\.js/);
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
@@ -169,6 +184,11 @@ for (const name of listFixtures()) {
|
||||
assert.equal(existsSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte')), false);
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'nuxt') {
|
||||
assert.equal(result.results[0].removed, true);
|
||||
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
assert.doesNotMatch(body, /impeccable-live-start/);
|
||||
|
||||
@@ -112,6 +112,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
|
||||
| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). |
|
||||
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
|
||||
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
|
||||
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
|
||||
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
|
||||
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Nuxt + Vite 7 Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<NuxtPage />
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
{
|
||||
"name": "Nuxt 4 + Vue 3 (static fixture only — runtime inject unsupported)",
|
||||
"name": "Nuxt 4 + Vue 3",
|
||||
"config": {
|
||||
"files": ["app.vue"],
|
||||
"insertBefore": "</body>",
|
||||
"files": ["app/app.vue"],
|
||||
"insertBefore": "</template>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["app.vue", "pages/index.vue", "nuxt.config.ts"],
|
||||
"sourceFiles": ["app/app.vue", "app/pages/index.vue", "nuxt.config.ts"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero in pages/index.vue",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "pages/index.vue"
|
||||
"expectedFile": "app/.impeccable-live/wraptest0/manifest.json",
|
||||
"expectedSourceFile": "app/pages/index.vue",
|
||||
"expectedPreviewMode": "vue-component"
|
||||
}
|
||||
],
|
||||
"_runtimeOmitted": "Nuxt's app.vue is a Vue template that compiles to a render function — a <script> tag inserted there renders as a DOM node but does not execute. Nuxt needs a config-based inject (nuxt.config.ts -> app.head.script), which live-inject.mjs does not currently support. Static checks (is-generated, inject syntax, wrap routing) still validate."
|
||||
"runtime": {
|
||||
"styling": "vue-scoped-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund"],
|
||||
"devCommand": ["npm", "run", "dev"],
|
||||
"scheme": "http",
|
||||
"ignoreHTTPSErrors": false,
|
||||
"readyPattern": "Local:\\s+http://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"pickSelector": "h1.hero-title",
|
||||
"steer": {
|
||||
"message": "steer-e2e mark hero",
|
||||
"sourceFile": "app/pages/index.vue",
|
||||
"expectSelector": "h1.hero-title[data-impeccable-steer=\"e2e\"]"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,10 @@
|
||||
<article class="feature-card">Two</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.feature-card {
|
||||
min-height: 64px;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+189
-2
@@ -5,11 +5,12 @@
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { sourceLockPath } from '../skill/scripts/live/source-lock.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ACCEPT = resolve(__dirname, '..', 'skill/scripts/live-accept.mjs');
|
||||
@@ -29,6 +30,165 @@ function runAccept(cwd, args) {
|
||||
}
|
||||
}
|
||||
|
||||
// The failure that broke the first real Claude Code Live run. Progressive
|
||||
// publication stages `.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, which
|
||||
// carries the session marker. findSessionFile walks `src`, `app`, `pages`, ... and
|
||||
// then `.`; a project whose source is not under one of those (this repo's own site
|
||||
// lives in `site/pages/`) falls through to the `.` walk, where dot-directories sort
|
||||
// before letters — so the artifact was found before the real file.
|
||||
describe('live-accept — marker search must ignore Impeccable state', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-decoy-')); });
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const SOURCE = [
|
||||
'<main>',
|
||||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||||
'<div data-impeccable-variant="original">ORIGINAL</div>',
|
||||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||||
'</main>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function seed({ revisions = 3 } = {}) {
|
||||
mkdirSync(join(tmp, 'site', 'pages'), { recursive: true });
|
||||
mkdirSync(join(tmp, '.impeccable', 'live', 'artifacts'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'site', 'pages', 'index.astro'), SOURCE);
|
||||
for (let r = 1; r <= revisions; r += 1) {
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'artifacts', `ab12cd34-r${r}.astro`), SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts into real source when a staged artifact carries the same marker', () => {
|
||||
seed();
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
|
||||
assert.equal(result.handled, true, JSON.stringify(result));
|
||||
assert.equal(
|
||||
result.file,
|
||||
'site/pages/index.astro',
|
||||
'accept must resolve the project file, not the .impeccable artifact decoy',
|
||||
);
|
||||
const source = readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8');
|
||||
assert.match(source, /VARIANT ONE/);
|
||||
assert.doesNotMatch(source, /impeccable-variants-start/, 'the wrapper must be gone from real source');
|
||||
});
|
||||
|
||||
it('discards into real source with an artifact decoy present', () => {
|
||||
seed({ revisions: 1 });
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--discard']);
|
||||
assert.equal(result.handled, true, JSON.stringify(result));
|
||||
assert.equal(result.file, 'site/pages/index.astro');
|
||||
assert.match(readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8'), /ORIGINAL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-accept — session id validation', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-id-')); });
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// --id becomes a path segment for the accept receipt. Traversal here wrote
|
||||
// JSON to arbitrary absolute paths (e.g. `--id ../../../../etc/evil`).
|
||||
for (const id of ['../../../../etc/evil', 'a/b', '..', 'a\\b', '']) {
|
||||
it(`refuses --id ${JSON.stringify(id)} without writing a receipt`, () => {
|
||||
const res = spawnSync('node', [ACCEPT, '--id', id, '--discard'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.equal(res.status, 1, 'must exit non-zero');
|
||||
assert.match(res.stderr, /Invalid --id|Missing --id/);
|
||||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false);
|
||||
});
|
||||
}
|
||||
|
||||
it('still accepts a well-formed id', () => {
|
||||
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--discard'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.doesNotMatch(res.stderr || '', /Invalid --id/);
|
||||
});
|
||||
|
||||
// --variant is interpolated into a RegExp and into the markup written back to
|
||||
// source. `.*` matched the `original` block first, so the CLI reported a
|
||||
// successful accept while actually restoring the original.
|
||||
for (const variant of ['.*', '[12]', 'original', '1e2', '']) {
|
||||
it(`refuses --variant ${JSON.stringify(variant)} rather than matching by regex`, () => {
|
||||
writeFileSync(join(tmp, 'page.html'), [
|
||||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||||
'<div data-impeccable-variant="original">ORIGINAL CONTENT</div>',
|
||||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||||
'',
|
||||
].join('\n'));
|
||||
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--variant', variant], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Invalid --variant|Need --discard/);
|
||||
assert.match(
|
||||
readFileSync(join(tmp, 'page.html'), 'utf-8'),
|
||||
/impeccable-variants-start/,
|
||||
'a rejected variant must leave the wrapper untouched',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The plain wrapper is the only non-component preview path now that the isolated
|
||||
// source-artifact mode is gone, so its lock-contention behaviour is what carries
|
||||
// these guarantees.
|
||||
describe('live-accept — plain wrapper under source-lock contention', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-lock-')); });
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const PAGE = [
|
||||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||||
'<div data-impeccable-variant="original">ORIGINAL</div>',
|
||||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function holdLock() {
|
||||
// realpath: mkdtemp hands back /var/... on macOS while the child's cwd
|
||||
// resolves to /private/var/..., and the lock digest hashes the absolute path.
|
||||
const realTmp = realpathSync(tmp);
|
||||
const lockPath = sourceLockPath(join(realTmp, 'page.html'), realTmp);
|
||||
mkdirSync(dirname(lockPath), { recursive: true });
|
||||
// process.pid is alive, so the lock is a live holder rather than stale.
|
||||
writeFileSync(lockPath, JSON.stringify({
|
||||
owner: 'generation:ab12cd34:1', token: 'other', pid: process.pid, at: Date.now(),
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
for (const [label, args] of [['accept', ['--variant', '1']], ['discard', ['--discard']]]) {
|
||||
it(`reports a blocked ${label} as mode:error rather than a manual handoff`, () => {
|
||||
writeFileSync(join(tmp, 'page.html'), PAGE);
|
||||
holdLock();
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', ...args]);
|
||||
assert.equal(result.handled, false, JSON.stringify(result));
|
||||
assert.equal(result.error, 'source_locked');
|
||||
// Without mode:error, completion.mjs classifies this as agent_done with an ok
|
||||
// ack and live.md tells the agent to hand-edit the file — racing the publisher
|
||||
// that holds the lock.
|
||||
assert.equal(result.mode, 'error');
|
||||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), PAGE, 'source must be untouched');
|
||||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false, 'no receipt for a failed op');
|
||||
});
|
||||
}
|
||||
|
||||
it('succeeds once the lock is gone', () => {
|
||||
writeFileSync(join(tmp, 'page.html'), PAGE);
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
|
||||
assert.equal(result.handled, true, JSON.stringify(result));
|
||||
assert.match(readFileSync(join(tmp, 'page.html'), 'utf-8'), /VARIANT ONE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-accept — style-element edge cases', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-test-')); });
|
||||
@@ -74,6 +234,33 @@ describe('live-accept — style-element edge cases', () => {
|
||||
assert.ok(!after.includes('original text'), 'original content dropped');
|
||||
});
|
||||
|
||||
it('replays a durable receipt when Accept is retried after source was already written', () => {
|
||||
const html = `<body>
|
||||
<!-- impeccable-variants-start RECEIPT1 -->
|
||||
<div data-impeccable-variants="RECEIPT1" data-impeccable-variant-count="2" style="display: contents">
|
||||
<div data-impeccable-variant="original"><p>original</p></div>
|
||||
<style data-impeccable-css="RECEIPT1" />
|
||||
<div data-impeccable-variant="1"><p>accepted once</p></div>
|
||||
<div data-impeccable-variant="2" style="display: none"><p>other</p></div>
|
||||
</div>
|
||||
<!-- impeccable-variants-end RECEIPT1 -->
|
||||
</body>`;
|
||||
writeFileSync(join(tmp, 'page.html'), html);
|
||||
|
||||
const first = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
|
||||
const afterFirst = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||||
const replay = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
|
||||
|
||||
assert.equal(first.handled, true);
|
||||
assert.equal(replay.handled, true);
|
||||
assert.equal(replay.alreadyApplied, true);
|
||||
assert.equal(replay.file, 'page.html');
|
||||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), afterFirst);
|
||||
const conflict = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '2']);
|
||||
assert.equal(conflict.handled, false);
|
||||
assert.equal(conflict.error, 'accept_receipt_conflict');
|
||||
});
|
||||
|
||||
// Variant: same-line <style>…</style> block should also be treated as a
|
||||
// single skipped unit; the line has both open and close tags.
|
||||
it('finds the accepted variant after a single-line <style>…</style> block', () => {
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a Svelte-gated painted-ancestor crop proxy for shader capture', () => {
|
||||
it('uses a framework-component-gated painted-ancestor crop proxy for shader capture', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function findShaderProxyCaptureRoot\(el\) \{[\s\S]{0,500}?let node = el\.parentElement;[\s\S]{0,700}?containsElement && paintsShaderProxySurface\(node\)[\s\S]{0,120}?return null;/,
|
||||
@@ -87,8 +87,8 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?currentPreviewMode === 'svelte-component' \|\| svelteComponentSession[\s\S]{0,260}?dataset\?\.impeccablePreview === 'svelte-component';/,
|
||||
'ancestor crop proxy must be gated to the Svelte adapter / Svelte component previews',
|
||||
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?isFrameworkComponentPreviewMode\(currentPreviewMode\) \|\| svelteComponentSession[\s\S]{0,260}?isFrameworkComponentPreviewMode\(wrapper\?\.dataset\?\.impeccablePreview\);/,
|
||||
'ancestor crop proxy must be gated to Svelte/Vue component previews',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -141,11 +141,30 @@ describe('live-browser.js regression guards', () => {
|
||||
it('restores unsaved inline edit drafts before hideBar tears editing down', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function hideBar\(\) \{[\s\S]{0,620}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/,
|
||||
/function hideBar\(instant\) \{[\s\S]{0,720}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/,
|
||||
'hideBar should not leave unsaved contenteditable drafts in the DOM when an external event hides the bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('discards variants without hiding the original or animating stale chrome', () => {
|
||||
assert.match(SOURCE, /function showOriginalDuringDiscard\(sessionId\)[\s\S]{0,900}?data-impeccable-variant="original"/);
|
||||
assert.match(SOURCE, /function handleDiscard\(\)[\s\S]{0,420}?cleanup\(\{ restoreOriginal: true, instantChrome: true \}\)/);
|
||||
assert.match(SOURCE, /if \(instant\) barEl\.style\.display = 'none'/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(restoreOriginal\) showOriginalDuringDiscard\(cleanupSessionId\);\s*else wrapper\.style\.display = 'none';/,
|
||||
'only non-discard cleanup may blank the wrapper while waiting for HMR',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores live state off the document root and preserves the selected anchor top', () => {
|
||||
assert.match(SOURCE, /window\.__IMPECCABLE_LIVE_STATE__ = next/);
|
||||
assert.doesNotMatch(SOURCE, /document\.documentElement\.dataset\.impeccableLiveState/);
|
||||
assert.match(SOURCE, /pickedAnchorViewportTop: Number\.isFinite\(pickedAnchorViewportTop\)/);
|
||||
assert.match(SOURCE, /scrollLockAnchorTop = typeof initialAnchorTop === 'number' && isFinite\(initialAnchorTop\)/);
|
||||
assert.match(SOURCE, /const anchorDelta = anchorTop - scrollLockAnchorTop/);
|
||||
});
|
||||
|
||||
it('does not autofocus the steering chat while inline editing', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -443,6 +462,25 @@ describe('live-browser.js regression guards', () => {
|
||||
/function syncAgentPollingUi\(/,
|
||||
'global bar brand must reflect agent poll connectivity',
|
||||
);
|
||||
// The indicator goes quiet both when nobody is polling and when the agent
|
||||
// holds leased work. Under one-shot foreground polling the second case is
|
||||
// every normal generation, so a single "run live-poll.mjs to connect" tip
|
||||
// told users to fix a healthy session.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function agentHasWorkInFlight\(\)\s*\{\s*return state === 'GENERATING' \|\| state === 'SAVING';/,
|
||||
'agent poll copy must distinguish a busy agent from an absent one',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/agentHasWorkInFlight\(\) \? AGENT_BUSY_TIP : AGENT_DISCONNECTED_TIP/,
|
||||
'a busy agent must not be described as disconnected',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/tip\.textContent = agentStatusText\(\)/,
|
||||
'tooltip copy must be derived at display time, not read from a cache the 5s status poll last wrote',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'agent_polling':/,
|
||||
@@ -841,6 +879,58 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('makes every arrived progressive variant immediately actionable', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(arrivedVariants > 0\) \{[\s\S]{0,180}?setLiveState\('CYCLING'\)/,
|
||||
'the first arrived variant should leave the generating-only state',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?accept\.style\.pointerEvents = 'none'/,
|
||||
'Accept must not wait for variants the user did not choose',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?discard\.style\.pointerEvents = 'none'/,
|
||||
'Discard must cancel remaining work immediately',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const resumedState = arrivedVariants > 0 \? 'CYCLING' : 'GENERATING'/,
|
||||
'reload recovery should preserve a partially delivered review state',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/arrivedVariants >= expectedVariants && expectedVariants > 0[\s\S]{0,100}?\? 'variants_ready'[\s\S]{0,60}?: 'variants_progress'/,
|
||||
'checkpoint timing must distinguish partial review from complete delivery by counts',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps deferred Tune controls visible and refreshes params-only publications', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const paramsPending = !hasParams && \(parameterGenerationState === 'pending' \|\| parameterGenerationState === 'loading'\)/,
|
||||
'the cycling bar must expose Tune while parameter generation is outstanding',
|
||||
);
|
||||
assert.match(SOURCE, /tune\.disabled = true/, 'pending Tune must be visibly loading but non-interactive');
|
||||
assert.match(SOURCE, /Tune controls are ready\./, 'parameter arrival needs a clear ready indication');
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/msg\.publicationKind !== 'params' && arrivedVariants >= targetArrived/,
|
||||
'a params-only publication must refresh even though the variant count is unchanged',
|
||||
);
|
||||
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
|
||||
});
|
||||
|
||||
it('promotes an early-accepted Svelte preview before releasing the picker', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,420}?if \(accepted\?\.isSvelteComponent\) \{[\s\S]{0,120}?commitAcceptedSvelteComponentToDom\(accepted\.id\);[\s\S]{0,120}?cleanupAcceptedSession\(\);/,
|
||||
'Svelte early accept must tear down its adapter mount before the next picking session starts',
|
||||
);
|
||||
});
|
||||
|
||||
it('variant injection resolves the picked anchor before entering recovery', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
|
||||
@@ -5,8 +5,48 @@ import { join } from 'node:path';
|
||||
|
||||
const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8');
|
||||
const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
|
||||
|
||||
describe('live-browser source contracts', () => {
|
||||
it('reports foreground poll connectivity without a background worker dependency', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/syncAgentPollingUi\(!!msg\.agentPolling\)/,
|
||||
'the initial SSE state should include foreground poll connectivity',
|
||||
);
|
||||
assert.doesNotMatch(SOURCE, /codexWorker|codex-worker|codex_cli_unavailable/);
|
||||
});
|
||||
|
||||
it('routes Nuxt Vue preview modules through the Vite build-assets base', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function resolveComponentModuleUrl\(manifest, modulePath\)[\s\S]*?manifest\?\.previewMode === 'vue-component'[\s\S]*?window\.__NUXT__\?\.config\?\.app\?\.buildAssetsDir[\s\S]*?pathValue\.slice\('\/@fs\/'.length\)/,
|
||||
'Nuxt must not send app-local preview modules through the page-route fallback',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const moduleBase = manifest\.componentModuleBase[\s\S]*?resolveComponentModuleUrl\(manifest, modulePath\)/,
|
||||
'Vue SFC variants should use the manifest Vite module base rather than componentDir as a route URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches plain generation before screenshot capture without bypassing annotated evidence', () => {
|
||||
const dispatchIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await sendEvent(basePayload);');
|
||||
const captureIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await captureElementToBlob');
|
||||
assert.ok(dispatchIndex >= 0, 'plain generation should dispatch immediately');
|
||||
assert.ok(captureIndex > dispatchIndex, 'plain generation dispatch must happen before capture begins');
|
||||
assert.match(
|
||||
CAPTURE_AND_EMIT_SOURCE,
|
||||
/if \(blob && hasAnnotations\)[\s\S]*?\/annotation\?token=/,
|
||||
'annotation screenshots should still upload before annotated generation dispatch',
|
||||
);
|
||||
assert.match(
|
||||
CAPTURE_AND_EMIT_SOURCE,
|
||||
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
|
||||
'annotated generation should dispatch exactly after capture and upload resolve',
|
||||
);
|
||||
});
|
||||
|
||||
it('saves copy edits to the staged buffer with rich AI context', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
@@ -285,7 +325,7 @@ describe('live-browser source contracts', () => {
|
||||
assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/);
|
||||
});
|
||||
|
||||
it('waits for post-carbonize completion before final accepted DOM cleanup', () => {
|
||||
it('releases the foreground picker after deterministic accept while carbonize finishes', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/let pendingAcceptedSession = null;/,
|
||||
@@ -309,8 +349,8 @@ describe('live-browser source contracts', () => {
|
||||
const agentDoneStart = SOURCE.indexOf("case 'agent_done':");
|
||||
const errorCaseStart = SOURCE.indexOf("case 'error':", agentDoneStart);
|
||||
const agentDoneSource = SOURCE.slice(agentDoneStart, errorCaseStart);
|
||||
assert.match(agentDoneSource, /Carbonize accepts are not terminal/);
|
||||
assert.match(agentDoneSource, /break;/);
|
||||
assert.match(agentDoneSource, /must not hold the foreground picker hostage/);
|
||||
assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
|
||||
@@ -319,15 +359,15 @@ describe('live-browser source contracts', () => {
|
||||
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
|
||||
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
|
||||
const handleAcceptSource = SOURCE.slice(handleAcceptStart, maybeCompleteStart);
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
handleAcceptSource,
|
||||
/state = 'CONFIRMED'|cleanupAcceptedSession\(|hideBar\(\)/,
|
||||
'accept enqueue should not clear or confirm the browser session before source cleanup completes',
|
||||
/sendEvent\(acceptPayload, \{ throwOnError: true \}\)[\s\S]*?markSessionHandled\(\);[\s\S]*?setLiveState\('CONFIRMED'\);[\s\S]*?scheduleAcceptCleanup\(pending\);/,
|
||||
'durable accept intent should release the foreground picker before background source cleanup completes',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?acceptedDomAlreadyClean\(accepted\)[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?\}, 1800\);/,
|
||||
'post-cleanup fallback should give HMR a second chance before mutating React-owned DOM',
|
||||
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?\}, 1200\);/,
|
||||
'foreground cleanup should be immediate while the no-HMR DOM fallback stays deferred',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -393,4 +433,12 @@ describe('live-browser source contracts', () => {
|
||||
'source fallback should translate simple JSX style objects such as display:none',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads progressive source checkpoints through the no-HMR fallback', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'variant_progress':[\s\S]{0,1400}?msg\.previewMode === 'source'[\s\S]{0,1000}?arrivedVariants >= targetArrived[\s\S]{0,260}?injectVariantsFromSource\(msg\.previewFile \|\| msg\.file, msg\.id\)/,
|
||||
'source-mode progress should let framework HMR settle before using the no-HMR fallback',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,28 @@ describe('live completion type classification', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Component previews keep their variants in module files, not in the user's
|
||||
// source, so a failed accept leaves nothing to hand-edit: that is a failure, not
|
||||
// live.md's "read file, find markers, edit" handoff. Only svelte-component was
|
||||
// special cased, so the identical failure on a Vue preview read as success.
|
||||
for (const previewMode of ['svelte-component', 'vue-component']) {
|
||||
it(`treats a failed ${previewMode} accept as an error, not a manual handoff`, () => {
|
||||
assert.equal(
|
||||
completionTypeForAcceptResult('accept', { handled: false, error: 'source_locked', previewMode }),
|
||||
'error',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('still treats a failed plain-wrapper accept as a manual handoff', () => {
|
||||
// The one shape with editable markers in source. This must not regress into
|
||||
// an error, or every hand-editable session starts failing the poll loop.
|
||||
assert.equal(
|
||||
completionTypeForAcceptResult('accept', { handled: false, error: 'Markers not found' }),
|
||||
'agent_done',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies handled accept/discard and real failures explicitly', () => {
|
||||
assert.equal(completionTypeForAcceptResult('accept', { handled: true }), 'complete');
|
||||
assert.equal(completionTypeForAcceptResult('discard', { handled: true }), 'discarded');
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { htmlToJsx, normalizeVariantOutput } from './live-e2e/agent.mjs';
|
||||
import {
|
||||
htmlToJsx,
|
||||
isExpectedGenerationCancellation,
|
||||
normalizeVariantOutput,
|
||||
} from './live-e2e/agent.mjs';
|
||||
|
||||
describe('live-e2e agent output translation', () => {
|
||||
it('treats a fenced late generation as expected cancellation only', () => {
|
||||
assert.equal(isExpectedGenerationCancellation(new Error('Source publication prepare failed: stale_generation_epoch')), true);
|
||||
assert.equal(isExpectedGenerationCancellation(new Error('Source publication failed: stale_source_revision')), false);
|
||||
assert.equal(isExpectedGenerationCancellation(new Error('provider unavailable')), false);
|
||||
});
|
||||
|
||||
it('converts HTML class and inline style attributes to JSX syntax', () => {
|
||||
const jsx = htmlToJsx(
|
||||
'<h1 class="hero-title" style="--p-scale:1; font-size:2.25rem; font-weight:700">Title</h1>',
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
createLlmAgent,
|
||||
parseManualEditResponse,
|
||||
parseVariantResponse,
|
||||
progressiveVariantGuidance,
|
||||
resolveLlmAgentConfig,
|
||||
validateManualEditCoverage,
|
||||
validateManualEditPlanningCoverage,
|
||||
validateVariantMaterialChange,
|
||||
validateVariantCount,
|
||||
validateProgressiveVariantOutput,
|
||||
validateVariantVisibleCopy,
|
||||
} from './live-e2e/agents/llm-agent.mjs';
|
||||
|
||||
@@ -1459,6 +1462,19 @@ describe('live-e2e LLM agent manual edit coverage validation', () => {
|
||||
});
|
||||
|
||||
describe('live-e2e LLM agent variant prompt', () => {
|
||||
it('makes progressive phase boundaries and lazy parameters explicit', () => {
|
||||
const first = progressiveVariantGuidance({ count: 1, progressive: { phase: 'first' } });
|
||||
const remaining = progressiveVariantGuidance({
|
||||
count: 3,
|
||||
progressive: { phase: 'remaining', omitFirstVariantCss: true },
|
||||
});
|
||||
assert.match(first, /params: \[\]/);
|
||||
assert.match(first, /materially different/);
|
||||
assert.match(remaining, /complete final set of exactly 3 variants/);
|
||||
assert.match(remaining, /Keep its innerHtml exactly unchanged/);
|
||||
assert.match(remaining, /Do not repeat or modify any scopedCss rule/);
|
||||
});
|
||||
|
||||
it('tells the model not to nest duplicate picked containers', () => {
|
||||
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /replacement root itself/);
|
||||
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /do not wrap a duplicate/);
|
||||
@@ -1484,6 +1500,53 @@ describe('live-e2e LLM agent variant prompt', () => {
|
||||
});
|
||||
|
||||
describe('live-e2e LLM agent variant copy validation', () => {
|
||||
it('enforces the exact requested variant count', () => {
|
||||
const parsed = { scopedCss: '', variants: [{ innerHtml: '<h1>One</h1>', params: [] }] };
|
||||
assert.match(validateVariantCount(parsed, { count: 2 }), /expected exactly 2 variants, received 1/);
|
||||
assert.equal(validateVariantCount(parsed, { count: 1 }), null);
|
||||
});
|
||||
|
||||
it('defers progressive params and preserves the visible first variant', () => {
|
||||
const firstHtml = '<h1 class="hero-title"><span>One</span></h1>';
|
||||
assert.match(
|
||||
validateProgressiveVariantOutput(
|
||||
{ variants: [{ innerHtml: firstHtml, params: [{ id: 'weight' }] }] },
|
||||
{ progressive: { phase: 'first' } },
|
||||
),
|
||||
/defer params/,
|
||||
);
|
||||
assert.equal(
|
||||
validateProgressiveVariantOutput(
|
||||
{ variants: [{ innerHtml: firstHtml, params: [] }] },
|
||||
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.match(
|
||||
validateProgressiveVariantOutput(
|
||||
{ variants: [{ innerHtml: '<h1>Changed</h1>', params: [] }] },
|
||||
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
|
||||
),
|
||||
/preserve variant 1/,
|
||||
);
|
||||
assert.match(
|
||||
validateProgressiveVariantOutput(
|
||||
{
|
||||
scopedCss: '@scope ([data-impeccable-variant="1"]) { .hero-title { color: red; } }',
|
||||
variants: [{ innerHtml: firstHtml, params: [] }],
|
||||
},
|
||||
{
|
||||
progressive: {
|
||||
phase: 'remaining',
|
||||
firstVariant: { innerHtml: firstHtml },
|
||||
omitFirstVariantCss: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
/omit already-published variant 1 CSS/,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows variants that preserve the picked element text', () => {
|
||||
const result = validateVariantVisibleCopy(
|
||||
{
|
||||
|
||||
+59
-11
@@ -22,7 +22,7 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
clickAccept,
|
||||
clickApplyEdits,
|
||||
clickEditCopy,
|
||||
clickDiscard,
|
||||
clickSaveEdit,
|
||||
clickGo,
|
||||
clickNext,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
editTextLeaf,
|
||||
drawAnnotationPinAndStroke,
|
||||
getVisibleVariant,
|
||||
installLiveQueryHelpers,
|
||||
pickElement,
|
||||
runLiveChromeBottomBarSmoke,
|
||||
waitForApplyDockHidden,
|
||||
@@ -220,7 +222,7 @@ for (const { name, fixture } of fixtures) {
|
||||
const domSelector = isInsert
|
||||
? insertDomSelector
|
||||
: pickSelector;
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture);
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
|
||||
const variantContentSelector = isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
|
||||
: usesSvelteComponentPreview
|
||||
@@ -314,10 +316,11 @@ for (const { name, fixture } of fixtures) {
|
||||
const after = readFileSync(sourceFile, 'utf-8');
|
||||
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
|
||||
if (svelteComponentSession) {
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte');
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
|
||||
const variantBody = readFileSync(variantFile, 'utf-8');
|
||||
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
assert.match(after, /"previewMode": "svelte-component"/, 'Svelte component manifest inserted');
|
||||
assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted');
|
||||
if (isInsert) {
|
||||
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
|
||||
if (agentMode === 'fake') {
|
||||
@@ -328,9 +331,9 @@ for (const { name, fixture } of fixtures) {
|
||||
assert.match(variantBody, /<([a-z][\w:-]*)\b[\s\S]*<\/\1>|<[a-z][\w:-]*\b[^>]*\/>/i, 'Svelte insert variant component contains a root element');
|
||||
}
|
||||
} else {
|
||||
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'Svelte variant component contains target element');
|
||||
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'component variant contains target element');
|
||||
}
|
||||
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'Svelte route source is not edited during generation');
|
||||
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'route source is not edited during component preview');
|
||||
} else {
|
||||
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
|
||||
}
|
||||
@@ -349,7 +352,8 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
}
|
||||
if (svelteComponentSession) {
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /<style>/, 'Svelte component variant has scoped style block');
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
|
||||
} else if (sourceFile.endsWith('.astro')) {
|
||||
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
|
||||
assert.match(
|
||||
@@ -376,6 +380,13 @@ for (const { name, fixture } of fixtures) {
|
||||
for (const kind of ['range', 'steps', 'toggle']) {
|
||||
assert.match(paramsSource, new RegExp(`"kind"\\s*:\\s*"${kind}"`), `param kind ${kind} present`);
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|
||||
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| document;
|
||||
const tune = root.querySelector('[data-iceq-tune="1"]');
|
||||
return tune && tune.disabled === false && /Tune/.test(tune.textContent || '');
|
||||
}, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
// 6. Cycle variants. Most fixtures stop at variant 2; Svelte Insert
|
||||
@@ -649,6 +660,8 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) {
|
||||
const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || '';
|
||||
for (const scenario of fixture.runtime.manualEditScenarios) {
|
||||
@@ -798,6 +811,39 @@ function recordGenerateEvents(agent, events) {
|
||||
};
|
||||
}
|
||||
|
||||
function countSourceVariants(source) {
|
||||
return (String(source).match(/<div\s+data-impeccable-variant="(?!original")/g) || []).length;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function waitForGenerationTimings(tmp, id, { timeoutMs = 5_000, requireAllVariants = true } = {}) {
|
||||
const snapshotPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.snapshot.json`);
|
||||
const journalPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.jsonl`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastTimings = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(snapshotPath)) {
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
const timings = snapshot.generationTimings || {};
|
||||
lastTimings = timings;
|
||||
if (timings.generation_ready && timings.first_reviewable && (!requireAllVariants || timings.all_variants_ready)) return timings;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const checkpointReasons = existsSync(journalPath)
|
||||
? readFileSync(journalPath, 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)?.event)
|
||||
.filter((event) => event?.type === 'checkpoint')
|
||||
.map((event) => ({ reason: event.reason, arrivedVariants: event.arrivedVariants, expectedVariants: event.expectedVariants }))
|
||||
: [];
|
||||
throw new Error(`generation timings did not complete for ${id}: timings=${JSON.stringify(lastTimings)} checkpoints=${JSON.stringify(checkpointReasons)}`);
|
||||
}
|
||||
|
||||
async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error, log = () => {} }) {
|
||||
const root = process.env.IMPECCABLE_E2E_ARTIFACT_DIR;
|
||||
if (!root || !session?.tmp) return;
|
||||
@@ -1453,11 +1499,12 @@ function svelteComponentTargetFor(filePath) {
|
||||
if (!filePath.endsWith('/manifest.json') && !filePath.endsWith('\\manifest.json')) return null;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
|
||||
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
if (!['svelte-component', 'vue-component'].includes(manifest.previewMode) || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
const sep = pathSepFor(filePath);
|
||||
const markers = [
|
||||
`${sep}node_modules${sep}.impeccable-live${sep}`,
|
||||
`${sep}src${sep}lib${sep}impeccable${sep}`,
|
||||
`${sep}app${sep}.impeccable-live${sep}`,
|
||||
];
|
||||
const marker = markers.find((candidate) => filePath.includes(candidate));
|
||||
const idx = marker ? filePath.indexOf(marker) : -1;
|
||||
@@ -1547,18 +1594,19 @@ async function locateSessionFile(tmp) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
for (const f of walkSvelteComponentManifests(tmp)) {
|
||||
for (const f of walkComponentManifests(tmp)) {
|
||||
const body = readFileSync(f, 'utf-8');
|
||||
if (body.includes('"previewMode": "svelte-component"')) return f;
|
||||
if (/"previewMode": "(?:svelte|vue)-component"/.test(body)) return f;
|
||||
}
|
||||
throw new Error('Could not locate session source file under ' + tmp);
|
||||
}
|
||||
|
||||
function walkSvelteComponentManifests(root) {
|
||||
function walkComponentManifests(root) {
|
||||
const results = [];
|
||||
const stack = [
|
||||
join(root, 'node_modules/.impeccable-live'),
|
||||
join(root, 'src/lib/impeccable'),
|
||||
join(root, 'app/.impeccable-live'),
|
||||
];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
|
||||
+111
-11
@@ -1325,15 +1325,25 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
|
||||
styleMode: wrapInfo.styleMode,
|
||||
});
|
||||
|
||||
const endMarkerIdx = lines.findIndex((line, index) =>
|
||||
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
|
||||
);
|
||||
if (endMarkerIdx === -1) {
|
||||
throw new Error('end marker not found in ' + wrapInfo.file);
|
||||
}
|
||||
const tailIdx = wrapInfo.commentSyntax.open === '{/*'
|
||||
? endMarkerIdx
|
||||
: endMarkerIdx - 1;
|
||||
|
||||
const next = [
|
||||
...lines.slice(0, markerIdx + 1),
|
||||
block,
|
||||
...lines.slice(markerIdx + 1),
|
||||
...lines.slice(tailIdx),
|
||||
];
|
||||
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
|
||||
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
|
||||
const manifestPath = path.join(tmp, wrapInfo.file);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
const componentDir = path.join(tmp, manifest.componentDir);
|
||||
@@ -1373,7 +1383,52 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
|
||||
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
|
||||
if (writeParams) {
|
||||
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
manifest.arrivedVariants = output.variants.length;
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
|
||||
const manifestPath = path.join(tmp, wrapInfo.file);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
const componentDir = path.join(tmp, manifest.componentDir);
|
||||
const contract = Array.isArray(manifest.propContract) ? manifest.propContract : [];
|
||||
const textValues = extractTextPieces(event.element?.outerHTML || event.element?.textContent || '');
|
||||
const paramsByVariant = {};
|
||||
|
||||
for (let i = 0; i < output.variants.length; i++) {
|
||||
const variantId = i + 1;
|
||||
const variant = output.variants[i];
|
||||
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
|
||||
for (const entry of contract) {
|
||||
markup = markup.replaceAll(`{${entry.prop}}`, `{{ ${entry.prop} }}`);
|
||||
}
|
||||
const css = svelteCssForVariant(output.scopedCss || '', variantId, firstTagName(markup) || 'div');
|
||||
const propsScript = contract.length > 0
|
||||
? ['<script setup>', 'defineProps({', ...contract.map((entry) => ` ${entry.prop}: { default: '' },`), '});', '</script>', '']
|
||||
: [];
|
||||
const component = [
|
||||
...propsScript,
|
||||
'<template>',
|
||||
markup || '<div></div>',
|
||||
'</template>',
|
||||
'',
|
||||
'<style scoped>',
|
||||
css || ':where(*) {}',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(componentDir, `v${variantId}.vue`), component, 'utf-8');
|
||||
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
|
||||
}
|
||||
|
||||
if (writeParams) {
|
||||
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
manifest.arrivedVariants = output.variants.length;
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function variantMarkupHasVisibleContent(markup) {
|
||||
@@ -1507,6 +1562,8 @@ export async function runAgentLoop({
|
||||
agent,
|
||||
signal,
|
||||
log = () => {},
|
||||
trace = () => {},
|
||||
atomicDelayMs = 0,
|
||||
wrapTarget = { classes: 'hero-title', tag: 'h1' },
|
||||
steerSourceFile,
|
||||
steerTarget,
|
||||
@@ -1530,6 +1587,8 @@ export async function runAgentLoop({
|
||||
if (event.type === 'prefetch') continue;
|
||||
if (event.type === 'connected') continue;
|
||||
|
||||
trace('agent.event.received', { id: event.id, type: event.type, clientSentAt: event.clientSentAt ?? null });
|
||||
|
||||
if (event.type === 'steer') {
|
||||
log(`steer id=${event.id} message=${JSON.stringify(event.message)}`);
|
||||
try {
|
||||
@@ -1578,7 +1637,16 @@ export async function runAgentLoop({
|
||||
log(`generate id=${event.id} mode=${isInsert ? 'insert' : 'replace'}${isInsert ? '' : ` action=${event.action}`} count=${event.count}`);
|
||||
try {
|
||||
let wrapInfo;
|
||||
if (isInsert) {
|
||||
if (event.scaffold) {
|
||||
wrapInfo = event.scaffold;
|
||||
trace('agent.scaffold.reused', {
|
||||
id: event.id,
|
||||
file: wrapInfo.file,
|
||||
previewMode: wrapInfo.previewMode || 'source',
|
||||
durationMs: event.scaffoldDurationMs ?? null,
|
||||
});
|
||||
} else if (isInsert) {
|
||||
trace('agent.scaffold.start', { id: event.id, mode: 'insert' });
|
||||
const insertTarget = insertTargetFromEvent(event);
|
||||
wrapInfo = await runInsert({
|
||||
tmp,
|
||||
@@ -1587,7 +1655,9 @@ export async function runAgentLoop({
|
||||
count: event.count,
|
||||
...insertTarget,
|
||||
});
|
||||
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
|
||||
} else {
|
||||
trace('agent.scaffold.start', { id: event.id, mode: 'replace' });
|
||||
// 1. Wrap the original element in the variant scaffold (deterministic CLI)
|
||||
// wrapTarget can be a static {classes, tag, elementId} (test fixtures
|
||||
// know what they pick) or a function (event) => target (real-use
|
||||
@@ -1606,41 +1676,65 @@ export async function runAgentLoop({
|
||||
...target,
|
||||
text,
|
||||
});
|
||||
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
|
||||
}
|
||||
log(`scaffolded: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`);
|
||||
|
||||
// 2. Agent generates variant content (LLM-pluggable seam)
|
||||
let output = await agent.generateVariants(event, { wrapTarget, wrapInfo });
|
||||
output = normalizeVariantOutput(output, wrapInfo);
|
||||
// 2. Agent generates variant content (LLM-pluggable seam).
|
||||
// Providers may expose a true split path so variant 1 is written before
|
||||
// the request for the remaining variants completes.
|
||||
trace('agent.generate.start', { id: event.id, count: event.count });
|
||||
let output = normalizeVariantOutput(
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
|
||||
wrapInfo,
|
||||
);
|
||||
if (atomicDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
|
||||
}
|
||||
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
|
||||
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
|
||||
|
||||
if (output.variants.length !== event.count) {
|
||||
log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
|
||||
}
|
||||
|
||||
// 3. Write variants into the deterministic preview target.
|
||||
// 3. Write the complete set into the deterministic preview target.
|
||||
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output });
|
||||
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
} else if (wrapInfo.previewMode === 'vue-component') {
|
||||
await writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
} else {
|
||||
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
|
||||
}
|
||||
trace('agent.write.end', { id: event.id, file: wrapInfo.file });
|
||||
if (process.env.IMPECCABLE_E2E_DEBUG) {
|
||||
const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
|
||||
log(`--- post-splice (variants written) ---\n${post}`);
|
||||
}
|
||||
|
||||
// 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING)
|
||||
trace('agent.reply.start', { id: event.id });
|
||||
await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, type: 'done', id: event.id, file: wrapInfo.file }),
|
||||
body: JSON.stringify({ token, type: 'done', sourceEventType: 'generate', id: event.id, file: wrapInfo.file }),
|
||||
signal,
|
||||
});
|
||||
trace('agent.reply.end', { id: event.id });
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
if (isExpectedGenerationCancellation(err)) {
|
||||
trace('agent.generate.canceled', { id: event.id, reason: 'stale_generation_epoch' });
|
||||
log('generate canceled after Accept/Discard: ' + err.message);
|
||||
continue;
|
||||
}
|
||||
trace('agent.generate.error', { id: event.id, message: err.message });
|
||||
log('generate failed: ' + err.message);
|
||||
await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }),
|
||||
body: JSON.stringify({ token, type: 'error', sourceEventType: 'generate', id: event.id, message: err.message }),
|
||||
signal,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -1740,6 +1834,7 @@ export async function runAgentLoop({
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: completionType,
|
||||
sourceEventType: 'accept',
|
||||
id: event.id,
|
||||
file: acceptResult.file,
|
||||
message: acceptResult.error,
|
||||
@@ -1769,6 +1864,7 @@ export async function runAgentLoop({
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: completionType,
|
||||
sourceEventType: 'discard',
|
||||
id: event.id,
|
||||
file: discardResult.file,
|
||||
message: discardResult.error,
|
||||
@@ -1787,6 +1883,10 @@ export async function runAgentLoop({
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpectedGenerationCancellation(error) {
|
||||
return /(?:^|\b)stale_generation_epoch(?:\b|$)/.test(String(error?.message || error || ''));
|
||||
}
|
||||
|
||||
async function runPollReply({ tmp, scriptsDir, id, status, message, data }) {
|
||||
const args = [path.join(scriptsDir, 'live-poll.mjs'), '--reply', id, status];
|
||||
if (data !== undefined) args.push('--data', JSON.stringify(data));
|
||||
|
||||
@@ -192,6 +192,7 @@ const STEER_SYSTEM_INSTRUCTIONS = [
|
||||
* @property {string=} model Override the selected provider's default model.
|
||||
* @property {string=} baseURL Override the provider API base URL.
|
||||
* @property {object=} config Pre-resolved provider config from resolveLlmAgentConfig().
|
||||
* @property {boolean=} includeLiveSpec Attach the full live.md reference. Defaults to true; latency benchmarks disable it to export only the synthetic element contract.
|
||||
* @property {(msg: string) => void=} log Optional logger for debug output.
|
||||
*/
|
||||
|
||||
@@ -240,14 +241,22 @@ export async function createLlmAgent(opts = {}) {
|
||||
const { apiKey, baseURL, model, provider } = config;
|
||||
const log = opts.log || (() => {});
|
||||
|
||||
const liveMd = await fs.readFile(LIVE_MD_PATH, 'utf-8');
|
||||
const liveMd = opts.includeLiveSpec === false ? null : await fs.readFile(LIVE_MD_PATH, 'utf-8');
|
||||
const client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
const systemBlocks = (instructions) => [
|
||||
{
|
||||
type: 'text',
|
||||
text: liveMd ? instructions : instructions.replace(/\n\nCONTEXT —[^\n]+$/, ''),
|
||||
},
|
||||
...(liveMd ? [{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } }] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
async generateVariants(event, context = {}) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const baseUserMessage = [
|
||||
`Produce variants for the following ${isInsert ? 'insert request' : 'pick'}. Reply with the JSON object only — no prose.`,
|
||||
progressiveVariantGuidance(event),
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(buildVariantRequestPayload(event, context), null, 2),
|
||||
@@ -256,6 +265,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
|
||||
let userMessage = baseUserMessage;
|
||||
for (let attempt = 0; attempt < MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const lastAttempt = attempt + 1 >= MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS;
|
||||
let response;
|
||||
try {
|
||||
response = await client.messages.create(
|
||||
@@ -263,15 +273,10 @@ export async function createLlmAgent(opts = {}) {
|
||||
model,
|
||||
temperature: 0,
|
||||
max_tokens: 16000,
|
||||
system: [
|
||||
{ type: 'text', text: VARIANT_SYSTEM_INSTRUCTIONS },
|
||||
// Cacheable: the entire stable prefix (instructions + spec) is
|
||||
// cached up to this breakpoint. The user message holds all the
|
||||
// per-call volatile content. DeepSeek compatibility support is
|
||||
// provider-reported and best-effort; the usage log below tells us
|
||||
// whether cache reads/writes actually happened.
|
||||
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
// When present, live.md is the final cacheable stable prefix.
|
||||
// Benchmarks omit it so external payloads contain only the
|
||||
// synthetic element contract and per-run event.
|
||||
system: systemBlocks(VARIANT_SYSTEM_INSTRUCTIONS),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
},
|
||||
{
|
||||
@@ -280,7 +285,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (attempt === 1) throw err;
|
||||
if (lastAttempt) throw err;
|
||||
log(`variant request failed; retrying: ${err.message}`);
|
||||
userMessage = [
|
||||
baseUserMessage,
|
||||
@@ -300,7 +305,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
`provider=${provider} model=${model} attempt=${attempt + 1} input=${inputTokens} output=${outputTokens} cache_read=${cacheRead} cache_write=${cacheWrite}`,
|
||||
);
|
||||
if (!response || !Array.isArray(response.content)) {
|
||||
if (attempt === 1) throw new Error('LLM agent: provider returned an empty variant response');
|
||||
if (lastAttempt) throw new Error('LLM agent: provider returned an empty variant response');
|
||||
log('variant response validation failed; retrying: provider returned an empty response');
|
||||
userMessage = [
|
||||
baseUserMessage,
|
||||
@@ -320,7 +325,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
try {
|
||||
parsed = parseVariantResponse(text);
|
||||
} catch (err) {
|
||||
if (attempt === 1) throw err;
|
||||
if (lastAttempt) throw err;
|
||||
log(`variant response validation failed; retrying: ${err.message.split('\n')[0]}`);
|
||||
userMessage = [
|
||||
baseUserMessage,
|
||||
@@ -332,11 +337,13 @@ export async function createLlmAgent(opts = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validationError = isInsert
|
||||
? validateInsertVariantOutput(parsed, event)
|
||||
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element));
|
||||
const validationError = validateVariantCount(parsed, event)
|
||||
|| validateProgressiveVariantOutput(parsed, event)
|
||||
|| (isInsert
|
||||
? validateInsertVariantOutput(parsed, event)
|
||||
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element)));
|
||||
if (!validationError) return parsed;
|
||||
if (attempt === 1) throw new Error(`LLM agent: ${validationError}`);
|
||||
if (lastAttempt) throw new Error(`LLM agent: ${validationError}`);
|
||||
|
||||
log(`variant validation failed; retrying: ${validationError}`);
|
||||
if (isInsert) {
|
||||
@@ -411,10 +418,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
model,
|
||||
temperature: 0,
|
||||
max_tokens: 16000,
|
||||
system: [
|
||||
{ type: 'text', text: MANUAL_EDIT_SYSTEM_INSTRUCTIONS },
|
||||
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
system: systemBlocks(MANUAL_EDIT_SYSTEM_INSTRUCTIONS),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
},
|
||||
{
|
||||
@@ -542,10 +546,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: [
|
||||
{ type: 'text', text: STEER_SYSTEM_INSTRUCTIONS },
|
||||
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
|
||||
@@ -672,6 +673,7 @@ export function buildVariantRequestPayload(event, context = {}) {
|
||||
action: event?.action,
|
||||
freeformPrompt: event?.freeformPrompt,
|
||||
count: event?.count,
|
||||
progressive: event?.progressive,
|
||||
element: isInsert ? null : {
|
||||
outerHTML: event?.element?.outerHTML,
|
||||
tagName: event?.element?.tagName,
|
||||
@@ -691,6 +693,31 @@ export function buildVariantRequestPayload(event, context = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function progressiveVariantGuidance(event = {}) {
|
||||
if (event.progressive?.phase === 'first') {
|
||||
return [
|
||||
'PROGRESSIVE FIRST DELIVERY:',
|
||||
`- Return exactly ${event.count} variant now.`,
|
||||
'- Return params: [] for this variant; tunable parameters are generated in the final phase.',
|
||||
'- The innerHtml must be materially different from the picked source, not merely paired with different CSS.',
|
||||
'- For a bare-text element, preserve the full exact copy in one child span inside the unchanged root tag/class.',
|
||||
].join('\n');
|
||||
}
|
||||
if (event.progressive?.phase === 'remaining') {
|
||||
return [
|
||||
'PROGRESSIVE FINAL DELIVERY:',
|
||||
`- Return the complete final set of exactly ${event.count} variants, including variant 1.`,
|
||||
'- progressive.firstVariant is the already-visible variant 1. Keep its innerHtml exactly unchanged and add its deferred params now.',
|
||||
...(event.progressive.omitFirstVariantCss ? [
|
||||
'- Variant 1 CSS is already published and immutable. Do not repeat or modify any scopedCss rule for data-impeccable-variant="1"; return scopedCss rules for variants 2+ only.',
|
||||
] : []),
|
||||
'- Generate the remaining distinct variants and their params in the other array positions.',
|
||||
'- Every remaining variant innerHtml must be materially changed too; for bare text, wrap the full exact copy in one child span with a distinct class instead of relying on CSS alone.',
|
||||
].join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a model response into the variant-output schema. Throws
|
||||
* with a `Parsed (first 500 chars): ...` echo on every schema failure so the
|
||||
@@ -850,6 +877,30 @@ export function validateInsertVariantOutput(parsed, event = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateVariantCount(parsed, event = {}) {
|
||||
const expected = Number(event.count);
|
||||
if (!Number.isInteger(expected) || expected < 1) return 'event count must be a positive integer';
|
||||
const actual = Array.isArray(parsed?.variants) ? parsed.variants.length : 0;
|
||||
return actual === expected ? null : `expected exactly ${expected} variants, received ${actual}`;
|
||||
}
|
||||
|
||||
export function validateProgressiveVariantOutput(parsed, event = {}) {
|
||||
if (event.progressive?.phase === 'first') {
|
||||
const hasEarlyParams = (parsed.variants || []).some((variant) => Array.isArray(variant.params) && variant.params.length > 0);
|
||||
return hasEarlyParams ? 'progressive first delivery must defer params with an empty params array' : null;
|
||||
}
|
||||
if (event.progressive?.phase === 'remaining' && event.progressive.firstVariant?.innerHtml) {
|
||||
const expected = String(event.progressive.firstVariant.innerHtml).trim();
|
||||
const actual = String(parsed.variants?.[0]?.innerHtml || '').trim();
|
||||
if (actual !== expected) return 'progressive final delivery must preserve variant 1 innerHtml exactly';
|
||||
if (event.progressive.omitFirstVariantCss && /\[data-impeccable-variant\s*=\s*["']1["'][^\]]*\]/.test(parsed.scopedCss || '')) {
|
||||
return 'progressive final delivery must omit already-published variant 1 CSS';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateVariantMaterialChange(parsed, element) {
|
||||
const originalHtml = normalizeVariantHtml(element?.outerHTML || '');
|
||||
if (!originalHtml) return null;
|
||||
|
||||
+82
-20
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -32,8 +32,7 @@ export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
|
||||
// Stage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function stageFixture(name, fixture) {
|
||||
const fixtureRoot = join(FIXTURES_DIR, name);
|
||||
export function stageFixture(name, fixture, { fixtureRoot = join(FIXTURES_DIR, name) } = {}) {
|
||||
const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8');
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
|
||||
@@ -56,6 +55,7 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
|
||||
const installArgs = addNpmInstallDefaults(cmd, args);
|
||||
try {
|
||||
execFileSync(cmd, installArgs, { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
||||
repairMissingRollupOptionalBinary(tmp, { timeoutMs });
|
||||
} catch (err) {
|
||||
if (err.signal === 'SIGTERM' || err.signal === 'SIGKILL' || err.killed) {
|
||||
err.message = `fixture dependency install timed out after ${timeoutMs}ms: ${cmd} ${installArgs.join(' ')}`;
|
||||
@@ -64,11 +64,26 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
|
||||
}
|
||||
}
|
||||
|
||||
function repairMissingRollupOptionalBinary(tmp, { timeoutMs }) {
|
||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') return;
|
||||
const rollupPackage = join(tmp, 'node_modules', 'rollup', 'package.json');
|
||||
const nativePackage = join(tmp, 'node_modules', '@rollup', 'rollup-darwin-arm64', 'package.json');
|
||||
if (!existsSync(rollupPackage) || existsSync(nativePackage)) return;
|
||||
const version = JSON.parse(readFileSync(rollupPackage, 'utf-8')).version;
|
||||
execFileSync('npm', [
|
||||
'install', '--no-save', '--no-audit', '--no-fund', '--no-progress',
|
||||
`@rollup/rollup-darwin-arm64@${version}`,
|
||||
], { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
||||
}
|
||||
|
||||
function addNpmInstallDefaults(cmd, args) {
|
||||
if (cmd !== 'npm') return args;
|
||||
if (!['install', 'ci'].includes(args[0])) return args;
|
||||
const out = [...args];
|
||||
for (const flag of ['--prefer-offline', '--no-progress']) {
|
||||
// npm can omit platform-specific Rollup binaries unless optional
|
||||
// dependencies are requested explicitly (npm/cli#4828). Astro/Vite then
|
||||
// fail before Live starts on fresh staged fixtures.
|
||||
for (const flag of ['--no-progress', '--include=optional']) {
|
||||
if (!out.some((arg) => arg === flag || arg.startsWith(flag + '='))) out.push(flag);
|
||||
}
|
||||
return out;
|
||||
@@ -200,29 +215,54 @@ export async function stopDevServer(child) {
|
||||
* @param {object} opts
|
||||
* @param {string} opts.name fixture name
|
||||
* @param {object} opts.fixture fixture.json contents
|
||||
* @param {string=} opts.fixtureRoot fixture directory; defaults to the public framework fixture tree
|
||||
* @param {import('playwright').Browser} opts.browser shared browser instance
|
||||
* @param {object} opts.agent VariantAgent (defaults to fake)
|
||||
* @param {object|function=} opts.wrapTarget live-wrap target or event mapper
|
||||
* @param {(context: object) => Promise<object|void>} [opts.startWorker]
|
||||
* Optional production worker factory. Return {stop, done}; when used,
|
||||
* omit `agent` so the deterministic in-process loop is not started.
|
||||
* @param {(context: object) => Promise<void>|void} [opts.prepareTmp]
|
||||
* @param {(msg: string) => void} [opts.log]
|
||||
*/
|
||||
export async function bootFixtureSession({ name, fixture, browser, agent, wrapTarget, log = () => {} }) {
|
||||
export async function bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
fixtureRoot,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget,
|
||||
startWorker,
|
||||
prepareTmp,
|
||||
log = () => {},
|
||||
trace = () => {},
|
||||
atomicDelayMs = 0,
|
||||
keepTmp = false,
|
||||
}) {
|
||||
const runtime = fixture.runtime;
|
||||
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
|
||||
|
||||
const tmp = stageFixture(name, fixture);
|
||||
const tmp = stageFixture(name, fixture, { fixtureRoot });
|
||||
let live;
|
||||
let dev;
|
||||
let agentAbort;
|
||||
let agentDone;
|
||||
let externalWorker;
|
||||
let ctx;
|
||||
|
||||
const teardown = async () => {
|
||||
try { if (ctx) await ctx.close(); } catch {}
|
||||
try { if (agentAbort) agentAbort.abort(); } catch {}
|
||||
try { if (agentDone) await agentDone.catch(() => {}); } catch {}
|
||||
try { if (externalWorker?.stop) await externalWorker.stop(); } catch {}
|
||||
try { if (externalWorker?.done) await externalWorker.done.catch(() => {}); } catch {}
|
||||
try { if (dev?.child) await stopDevServer(dev.child); } catch {}
|
||||
try { if (live) stopLiveServer(tmp); } catch {}
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
if (!keepTmp) {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
} else {
|
||||
log(`kept staged fixture at ${tmp}`);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLiveForDeferredWork = () => {
|
||||
@@ -233,41 +273,60 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
if (prepareTmp) await prepareTmp({ tmp, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
||||
trace('setup.install.start', { fixture: name });
|
||||
log(`installing deps`);
|
||||
runInstall(tmp, runtime.install);
|
||||
trace('setup.install.end', { fixture: name });
|
||||
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
|
||||
|
||||
const liveStartedAt = Date.now();
|
||||
trace('setup.live_server.start', { fixture: name });
|
||||
log(`starting live-server`);
|
||||
live = startLiveServer(tmp);
|
||||
trace('setup.live_server.end', { fixture: name, port: live.port });
|
||||
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
|
||||
|
||||
if (startWorker) {
|
||||
trace('setup.worker.start', { fixture: name });
|
||||
externalWorker = await startWorker({ tmp, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
||||
trace('setup.worker.end', { fixture: name });
|
||||
}
|
||||
|
||||
const injectStartedAt = Date.now();
|
||||
trace('setup.inject.start', { fixture: name });
|
||||
log(`live-inject --port ${live.port}`);
|
||||
const injectResult = runInject(tmp, live.port);
|
||||
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
|
||||
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
|
||||
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
|
||||
|
||||
const devStartedAt = Date.now();
|
||||
trace('setup.dev_server.start', { fixture: name });
|
||||
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
|
||||
dev = startDevServer(tmp, runtime);
|
||||
const { port: devPort } = await dev.ready;
|
||||
trace('setup.dev_server.end', { fixture: name, port: devPort });
|
||||
log(`dev server ready on ${devPort} in ${formatDuration(Date.now() - devStartedAt)}`);
|
||||
|
||||
// Agent loop runs concurrently — abort on teardown.
|
||||
agentAbort = new AbortController();
|
||||
agentDone = runAgentLoop({
|
||||
tmp,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
port: live.port,
|
||||
token: live.token,
|
||||
agent,
|
||||
wrapTarget,
|
||||
signal: agentAbort.signal,
|
||||
log: (m) => log('[agent] ' + m),
|
||||
steerSourceFile: runtime.steer?.sourceFile,
|
||||
steerTarget: runtime.steer?.target,
|
||||
});
|
||||
if (agent) {
|
||||
agentAbort = new AbortController();
|
||||
const loopOptions = {
|
||||
tmp,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
port: live.port,
|
||||
token: live.token,
|
||||
agent,
|
||||
wrapTarget,
|
||||
signal: agentAbort.signal,
|
||||
trace,
|
||||
atomicDelayMs,
|
||||
steerSourceFile: runtime.steer?.sourceFile,
|
||||
steerTarget: runtime.steer?.target,
|
||||
};
|
||||
agentDone = Promise.all([runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]);
|
||||
}
|
||||
|
||||
const scheme = runtime.scheme || 'http';
|
||||
ctx = await browser.newContext({
|
||||
@@ -283,10 +342,12 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
});
|
||||
|
||||
const pageStartedAt = Date.now();
|
||||
trace('setup.page_load.start', { fixture: name });
|
||||
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
trace('setup.page_load.end', { fixture: name });
|
||||
log(`page loaded in ${formatDuration(Date.now() - pageStartedAt)}`);
|
||||
|
||||
return {
|
||||
@@ -295,6 +356,7 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
ctx,
|
||||
dev,
|
||||
live,
|
||||
worker: externalWorker,
|
||||
consoleErrors,
|
||||
stopLiveServer: stopLiveForDeferredWork,
|
||||
teardown,
|
||||
|
||||
+58
-4
@@ -424,7 +424,23 @@ export async function pickElement(page, selector, opts = {}) {
|
||||
if (visible) break;
|
||||
await resetPickMode(page);
|
||||
if (attempt === 2) {
|
||||
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 });
|
||||
const snapshot = await page.evaluate(({ selector, barSel, pickSel }) => {
|
||||
const target = document.querySelector(selector);
|
||||
const rect = target?.getBoundingClientRect();
|
||||
const hit = rect ? document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) : null;
|
||||
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
|
||||
const bar = query(barSel);
|
||||
const pick = query(pickSel);
|
||||
return {
|
||||
liveState: window.__IMPECCABLE_LIVE_STATE__ || null,
|
||||
target: target ? { tag: target.tagName, classes: target.className, rect: rect?.toJSON?.() || null } : null,
|
||||
hit: hit ? { tag: hit.tagName, classes: hit.className, text: (hit.textContent || '').slice(0, 80) } : null,
|
||||
pickActive: pick?.dataset.active || null,
|
||||
bar: bar ? { display: bar.style.display, text: bar.textContent } : null,
|
||||
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
|
||||
};
|
||||
}, { selector, barSel: BAR_ID, pickSel: PICK_TOGGLE_ID }).catch((error) => ({ error: error.message }));
|
||||
throw new Error(`pick did not open configure bar for ${selector}: ${JSON.stringify(snapshot)}`);
|
||||
}
|
||||
}
|
||||
// Wait specifically for the Configure-row submit button to be in the bar.
|
||||
@@ -528,6 +544,36 @@ export async function setCount(page, count) {
|
||||
throw new Error(`could not cycle count to ${count}`);
|
||||
}
|
||||
|
||||
/** Select a named Impeccable sub-command from the configure-row picker. */
|
||||
export async function selectAction(page, action) {
|
||||
const pickerSelector = '#impeccable-live-picker';
|
||||
const opened = await page.evaluate(({ barSel, pickerSel }) => {
|
||||
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
|
||||
const bar = query(barSel);
|
||||
const picker = query(pickerSel);
|
||||
const actionControl = [...(bar?.querySelectorAll('button') || [])]
|
||||
.find((button) => (button.textContent || '').includes('\u25BE'));
|
||||
if (!actionControl || !picker) return false;
|
||||
actionControl.click();
|
||||
return true;
|
||||
}, { barSel: BAR_ID, pickerSel: pickerSelector });
|
||||
if (!opened) throw new Error('could not open Live action picker');
|
||||
|
||||
await page.waitForFunction((selector) => {
|
||||
const picker = window.__impeccableLiveQuery(selector);
|
||||
return picker && picker.style.display !== 'none';
|
||||
}, pickerSelector, { timeout: 5_000 });
|
||||
|
||||
const selected = await page.evaluate(({ pickerSel, value }) => {
|
||||
const picker = window.__impeccableLiveQuery(pickerSel);
|
||||
const chip = picker?.querySelector(`button[data-action="${CSS.escape(value)}"]`);
|
||||
if (!chip) return false;
|
||||
chip.click();
|
||||
return true;
|
||||
}, { pickerSel: pickerSelector, value: action });
|
||||
if (!selected) throw new Error(`Live action ${JSON.stringify(action)} is unavailable`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Go. Browser POSTs the generate event; the agent picks it up. Headed
|
||||
* browser runs can occasionally accept the click without leaving configure
|
||||
@@ -578,7 +624,14 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
|
||||
// Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
|
||||
const m = text.match(/(\d+)\s*\/\s*(\d+)/);
|
||||
if (!m) return false;
|
||||
return parseInt(m[2], 10) === expected;
|
||||
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
|
||||
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
|
||||
? Number(debugState?.arrivedVariants || 0)
|
||||
: wrapper
|
||||
? wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length
|
||||
: 0;
|
||||
return parseInt(m[2], 10) === expected && arrived >= expected;
|
||||
},
|
||||
{ barSel: BAR_ID, expected: expectedCount },
|
||||
{ timeout },
|
||||
@@ -590,7 +643,7 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() || window.__IMPECCABLE_LIVE_UI_ROOT__ || null;
|
||||
const bar = query(barSel);
|
||||
const toast = query('#impeccable-live-toast');
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const wrapper = query('[data-impeccable-variants]');
|
||||
return {
|
||||
liveInit: window.__IMPECCABLE_LIVE_INIT__,
|
||||
adapter: window.__IMPECCABLE_LIVE_ADAPTER__,
|
||||
@@ -751,7 +804,8 @@ async function ensureVisibleVariant(page, expectedVariant) {
|
||||
*/
|
||||
export async function clickDiscard(page) {
|
||||
// The discard button has just a "✕" glyph as text content.
|
||||
await page.locator(`${BAR_ID} button`, { hasText: '✕' }).click();
|
||||
if (await dispatchBarButton(page, '✕')) return;
|
||||
await clickBarButton(page, '✕');
|
||||
}
|
||||
|
||||
export async function clickEditCopy(page) {
|
||||
|
||||
@@ -97,3 +97,16 @@ describe('validateEvent — replace generate (regression)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — worker progress', () => {
|
||||
it('accepts bounded agent phases and rejects malformed telemetry', () => {
|
||||
assert.equal(validateEvent({
|
||||
type: 'agent_phase',
|
||||
id: VALID_ID,
|
||||
phase: 'first_variant_generating',
|
||||
durationMs: 123,
|
||||
}), null);
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'Not valid' }), /phase/);
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'valid', durationMs: -1 }), /durationMs/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildGenerationPreflight,
|
||||
runGenerationPreflight,
|
||||
} from '../skill/scripts/live/generation-preflight.mjs';
|
||||
|
||||
const SCRIPTS_DIR = path.resolve('skill/scripts');
|
||||
|
||||
test('builds a replace preflight from the picker locator', () => {
|
||||
const command = buildGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-1',
|
||||
count: 3,
|
||||
pageUrl: '/pricing',
|
||||
element: {
|
||||
id: 'hero',
|
||||
classes: ['hero', 'hero--dark'],
|
||||
tagName: 'SECTION',
|
||||
textContent: 'A faster way to ship',
|
||||
},
|
||||
}, SCRIPTS_DIR);
|
||||
|
||||
assert.equal(command.mode, 'replace');
|
||||
assert.deepEqual(command.args.slice(1), [
|
||||
'--id', 'session-1', '--count', '3',
|
||||
'--element-id', 'hero',
|
||||
'--classes', 'hero hero--dark',
|
||||
'--tag', 'SECTION',
|
||||
'--text', 'A faster way to ship',
|
||||
'--page-url', '/pricing',
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds an insert preflight from the anchor locator', () => {
|
||||
const command = buildGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-2',
|
||||
count: 2,
|
||||
mode: 'insert',
|
||||
insert: {
|
||||
position: 'before',
|
||||
anchor: { classes: ['card'], tagName: 'ARTICLE', textContent: 'Plan' },
|
||||
},
|
||||
}, SCRIPTS_DIR);
|
||||
|
||||
assert.equal(command.mode, 'insert');
|
||||
assert.deepEqual(command.args.slice(1), [
|
||||
'--id', 'session-2', '--count', '2', '--position', 'before',
|
||||
'--classes', 'card', '--tag', 'ARTICLE', '--text', 'Plan',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns scaffold metadata without exposing child-process details', async () => {
|
||||
const calls = [];
|
||||
const result = await runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-3',
|
||||
count: 1,
|
||||
element: { classes: ['hero'] },
|
||||
}, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cwd: '/tmp/example',
|
||||
async execFileImpl(file, args, options) {
|
||||
calls.push({ file, args, options });
|
||||
return { stdout: '{"file":"src/App.jsx","insertLine":12}\n', stderr: '' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.scaffold, { file: 'src/App.jsx', insertLine: 12 });
|
||||
assert.equal(calls[0].file, process.execPath);
|
||||
assert.equal(calls[0].options.cwd, '/tmp/example');
|
||||
});
|
||||
|
||||
test('skips preflight when the picker has no source locator', async () => {
|
||||
const result = await runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-4',
|
||||
count: 3,
|
||||
element: { tagName: 'DIV' },
|
||||
}, { scriptsDir: SCRIPTS_DIR });
|
||||
|
||||
assert.deepEqual(result, { ok: false, skipped: true, reason: 'insufficient_locator' });
|
||||
});
|
||||
|
||||
test('yields to the event loop instead of blocking on the child process', async () => {
|
||||
// The server is single-threaded and leases polls through this call. A
|
||||
// synchronous spawn froze every other request (Accept, Discard, SSE) for the
|
||||
// scaffold's full duration — measured at ~7.6s on a large repo.
|
||||
let tickedDuringPreflight = false;
|
||||
const pending = runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-async',
|
||||
count: 1,
|
||||
element: { classes: ['hero'] },
|
||||
}, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
execFileImpl: () => new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ stdout: '{"file":"src/App.jsx"}\n', stderr: '' }), 25);
|
||||
}),
|
||||
});
|
||||
setTimeout(() => { tickedDuringPreflight = true; }, 5);
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(tickedDuringPreflight, true, 'the event loop must stay responsive during preflight');
|
||||
});
|
||||
|
||||
test('reports a child-process failure without leaking internals or throwing', async () => {
|
||||
const error = new Error('spawn failed');
|
||||
error.stderr = 'live-wrap.mjs: element not found\n';
|
||||
const result = await runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-fail',
|
||||
count: 1,
|
||||
element: { classes: ['hero'] },
|
||||
}, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
execFileImpl: () => Promise.reject(error),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'live-wrap.mjs: element not found');
|
||||
assert.ok(typeof result.durationMs === 'number');
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -389,4 +389,71 @@ const title = 'Test';
|
||||
const afterRemove = readFileSync(file, 'utf-8');
|
||||
assert.equal(afterRemove, original, 'CRLF file should round-trip cleanly after remove');
|
||||
});
|
||||
|
||||
it('uses an idempotent dev-only client plugin for a Nuxt 4 app directory', () => {
|
||||
const configSource = `export default defineNuxtConfig({\n devtools: { enabled: false },\n});\n`;
|
||||
const appSource = `<template>\n <NuxtPage />\n</template>\n`;
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), configSource);
|
||||
mkdirSync(join(tmp, 'app'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'app', 'app.vue'), appSource);
|
||||
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['app/app.vue'],
|
||||
insertBefore: '</template>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
const first = runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
const pluginPath = join(tmp, 'app', 'plugins', 'impeccable-live.client.ts');
|
||||
const firstPlugin = readFileSync(pluginPath, 'utf-8');
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.adapter, 'nuxt');
|
||||
assert.equal(first.results[0].file, 'app/plugins/impeccable-live.client.ts');
|
||||
assert.equal(first.results[0].changed, true);
|
||||
assert.match(firstPlugin, /if \(!import\.meta\.dev/);
|
||||
assert.match(firstPlugin, /data-impeccable-live-nuxt/);
|
||||
assert.match(firstPlugin, /localhost:8400\/live\.js/);
|
||||
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource, 'Nuxt config remains user-owned');
|
||||
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource, 'app.vue remains user-owned');
|
||||
|
||||
const second = runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.results[0].changed, false, 'same-port reinjection is byte-idempotent');
|
||||
assert.equal(readFileSync(pluginPath, 'utf-8'), firstPlugin);
|
||||
|
||||
const moved = runInject(tmp, cfgPath, ['--port', '8401']);
|
||||
assert.equal(moved.ok, true);
|
||||
assert.equal(moved.results[0].changed, true);
|
||||
assert.match(readFileSync(pluginPath, 'utf-8'), /localhost:8401\/live\.js/);
|
||||
assert.doesNotMatch(readFileSync(pluginPath, 'utf-8'), /localhost:8400\/live\.js/);
|
||||
|
||||
const removed = runInject(tmp, cfgPath, ['--remove']);
|
||||
assert.equal(removed.ok, true);
|
||||
assert.equal(removed.adapter, 'nuxt');
|
||||
assert.equal(removed.results[0].removed, true);
|
||||
assert.equal(existsSync(pluginPath), false);
|
||||
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource);
|
||||
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource);
|
||||
});
|
||||
|
||||
it('respects a literal Nuxt srcDir and never overwrites a user plugin', () => {
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), `export default defineNuxtConfig({ srcDir: 'client/' });\n`);
|
||||
mkdirSync(join(tmp, 'client', 'plugins'), { recursive: true });
|
||||
const pluginPath = join(tmp, 'client', 'plugins', 'impeccable-live.client.ts');
|
||||
const userPlugin = `export default defineNuxtPlugin(() => {});\n`;
|
||||
writeFileSync(pluginPath, userPlugin);
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['client/app.vue'],
|
||||
insertBefore: '</template>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
const result = runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.adapter, 'nuxt');
|
||||
assert.equal(result.results[0].error, 'nuxt_plugin_conflict');
|
||||
assert.equal(readFileSync(pluginPath, 'utf-8'), userPlugin);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Tests for live/poll-lanes.mjs — which pending event a poll gets next.
|
||||
* Run with: node --test tests/live-poll-lanes.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { eventPriority, selectAvailablePendingEvent } from '../skill/scripts/live/poll-lanes.mjs';
|
||||
|
||||
const entry = (type, seq, leaseUntil = 0, id = type + seq) => ({ event: { id, type }, leaseUntil, seq });
|
||||
|
||||
describe('poll lane priority', () => {
|
||||
it('puts terminal user actions ahead of generation', () => {
|
||||
for (const type of ['accept', 'discard', 'exit']) {
|
||||
assert.ok(
|
||||
eventPriority({ type }) < eventPriority({ type: 'generate' }),
|
||||
`${type} must outrank generate`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('ranks unknown event types last rather than first', () => {
|
||||
assert.ok(eventPriority({ type: 'something-new' }) > eventPriority({ type: 'generate' }));
|
||||
assert.ok(eventPriority({}) > eventPriority({ type: 'generate' }));
|
||||
});
|
||||
|
||||
// This is what makes the browser's optimistic Accept safe. The browser returns
|
||||
// to PICKING as soon as /events durably journals the accept, before the source
|
||||
// write happens, so the user can pick and hit Go while the accept is still
|
||||
// queued. If that generate were leased first, its preflight would wrap source
|
||||
// that still contains the previous session's variant markers.
|
||||
it('delivers a queued accept before a generate the user queued afterwards', () => {
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('accept', 1),
|
||||
entry('generate', 2),
|
||||
]);
|
||||
assert.equal(selected.event.type, 'accept');
|
||||
});
|
||||
|
||||
it('delivers the accept first even when the generate was queued earlier', () => {
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('generate', 1),
|
||||
entry('accept', 2),
|
||||
]);
|
||||
assert.equal(
|
||||
selected.event.type,
|
||||
'accept',
|
||||
'priority must beat arrival order, or a slow poller preflights against stale source',
|
||||
);
|
||||
});
|
||||
|
||||
it('breaks ties within one lane by arrival order', () => {
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('generate', 7),
|
||||
entry('generate', 3),
|
||||
]);
|
||||
assert.equal(selected.seq, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll lane availability', () => {
|
||||
it('skips an entry whose lease is still held', () => {
|
||||
const now = 1_000_000;
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('accept', 1, now + 30_000),
|
||||
entry('generate', 2),
|
||||
], { now });
|
||||
assert.equal(selected.event.type, 'generate', 'a leased accept must not be handed out twice');
|
||||
});
|
||||
|
||||
it('re-offers an entry once its lease has expired', () => {
|
||||
const now = 1_000_000;
|
||||
const selected = selectAvailablePendingEvent([entry('accept', 1, now - 1)], { now });
|
||||
assert.equal(selected.event.type, 'accept');
|
||||
});
|
||||
|
||||
it('returns null when everything is leased', () => {
|
||||
const now = 1_000_000;
|
||||
assert.equal(selectAvailablePendingEvent([entry('accept', 1, now + 5_000)], { now }), null);
|
||||
});
|
||||
|
||||
it('returns null for an empty queue', () => {
|
||||
assert.equal(selectAvailablePendingEvent([]), null);
|
||||
});
|
||||
|
||||
it('restricts delivery to the requested types', () => {
|
||||
const entries = [entry('accept', 1), entry('generate', 2)];
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: ['generate'] }).event.type, 'generate');
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: new Set(['generate']) }).event.type, 'generate');
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: ['steer'] }), null);
|
||||
});
|
||||
|
||||
it('ignores an empty or absent type filter instead of starving the queue', () => {
|
||||
const entries = [entry('generate', 1)];
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: null }).event.type, 'generate');
|
||||
assert.equal(selectAvailablePendingEvent(entries, {}).event.type, 'generate');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildPollReplyPayload,
|
||||
isEventPending,
|
||||
manualApplyPollBanner,
|
||||
normalizePollTypes,
|
||||
parseReplyArgs,
|
||||
requiresAgentReply,
|
||||
} from '../skill/scripts/live-poll.mjs';
|
||||
@@ -25,6 +26,15 @@ describe('live-poll reply payloads', () => {
|
||||
'event=live_poll.reply_data actor=agent operation=completion_ack risk=carbonize_flag_dropped_before_server_journal expected={"carbonize":true} actual=' + JSON.stringify(payload.data),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the leased source event type when concurrent work shares a session id', () => {
|
||||
const payload = buildPollReplyPayload('token-1', {
|
||||
id: 'abc12345',
|
||||
type: 'agent_done',
|
||||
sourceEventType: 'accept',
|
||||
});
|
||||
assert.equal(payload.sourceEventType, 'accept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-poll accept handling', () => {
|
||||
@@ -134,6 +144,7 @@ describe('live-poll stream helpers', () => {
|
||||
assert.equal(requiresAgentReply({ type: 'generate' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'steer' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'manual_edit_apply' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'carbonize_cleanup' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'prefetch' }), false);
|
||||
assert.equal(requiresAgentReply({ type: 'accept' }), false);
|
||||
assert.equal(requiresAgentReply({ type: 'timeout' }), false);
|
||||
@@ -149,4 +160,12 @@ describe('live-poll stream helpers', () => {
|
||||
assert.equal(isEventPending(status, 'abc12345'), true);
|
||||
assert.equal(isEventPending(status, '00000000'), false);
|
||||
});
|
||||
|
||||
it('normalizes a non-overlapping foreground control lane', () => {
|
||||
assert.deepEqual(
|
||||
normalizePollTypes('steer,manual_edit_apply,carbonize_cleanup,exit,steer'),
|
||||
['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'],
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { compileProviderBlocks } from '../scripts/lib/utils.js';
|
||||
import { PROVIDERS } from '../scripts/lib/transformers/providers.js';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
describe('live reference authoring contract', () => {
|
||||
it('keeps setup guidance focused on inferred target paths', () => {
|
||||
it('keeps setup guidance focused on routing live to its reference', () => {
|
||||
const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
|
||||
assert.match(skillSrc, /infer the concrete path and append `--target <path>` to the same command/);
|
||||
assert.match(skillSrc, /If the user invoked a sub-command[\s\S]*?reference\/<command>\.md/);
|
||||
assert.doesNotMatch(skillSrc, /Use this same scripts directory for all Impeccable helper commands/);
|
||||
assert.doesNotMatch(skillSrc, /walk upward for the nearest project `\.agents`, `\.claude`, or `\.cursor` skill/);
|
||||
assert.doesNotMatch(skillSrc, /## Context diagnostics/);
|
||||
@@ -22,7 +23,7 @@ describe('live reference authoring contract', () => {
|
||||
const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
|
||||
assert.match(skillSrc, /--target <path>/);
|
||||
assert.match(skillSrc, /If the user invoked a sub-command[\s\S]*?reference\/<command>\.md/);
|
||||
assert.doesNotMatch(skillSrc, /TARGET_SELECTION_REQUIRED/);
|
||||
assert.doesNotMatch(skillSrc, /productStatus/);
|
||||
assert.doesNotMatch(skillSrc, /designStatus/);
|
||||
@@ -40,13 +41,16 @@ describe('live reference authoring contract', () => {
|
||||
const openingContract = liveMd.split('\n').slice(0, 60).join('\n');
|
||||
|
||||
assert.match(liveMd, /1\. `live\.mjs`: boot\./);
|
||||
assert.match(liveMd, /3\. Poll loop with the default long timeout \(600000 ms\)\. After every event or `--reply`, run `live-poll\.mjs` again immediately\. Never pass a short `--timeout=`\./);
|
||||
assert.match(liveMd, /3\. Poll loop with the default long timeout \(600000 ms\)\. Run `live-poll\.mjs` again immediately.*Codex runs this one-shot poll in the foreground\./);
|
||||
assert.match(openingContract, /## Poll loop/);
|
||||
assert.match(openingContract, /No step skipped, no step reordered\./);
|
||||
assert.doesNotMatch(liveMd, /live-copy-edits\.md/);
|
||||
assert.doesNotMatch(liveMd, /IMPECCABLE_LIVE_COPY_AGENT|mock/);
|
||||
assert.match(liveMd, /"manual_edit_apply" → Handle Manual Edit Apply/);
|
||||
assert.match(liveMd, /## Handle `manual_edit_apply`/);
|
||||
assert.match(openingContract, /Codex.*one-shot poll in a \*\*yielded foreground exec session\*\*/);
|
||||
assert.doesNotMatch(openingContract, /dedicated app-server generation lane by default/);
|
||||
assert.doesNotMatch(liveMd, /app-server|IMPECCABLE_LIVE_CODEX_WORKER|codexWorker/);
|
||||
assert.ok(
|
||||
liveMd.indexOf('## Handle `manual_edit_apply`') > liveMd.indexOf('## Handle `prefetch`'),
|
||||
'manual_edit_apply handler section must sit after prefetch in the dispatch order',
|
||||
@@ -60,6 +64,25 @@ describe('live reference authoring contract', () => {
|
||||
assert.match(liveMd, /delegate source edits to `impeccable_manual_edit_applier`/);
|
||||
assert.match(liveMd, /The subagent must not poll or reply/);
|
||||
assert.match(liveMd, /parent live thread keeps the foreground poll loop/);
|
||||
// Generation stays in the main thread on every harness. The generator subagent
|
||||
// was removed after the first real Claude Code run: the parent has to
|
||||
// hand-compress the design system into the handoff, and compression is lossy.
|
||||
// It shipped 0 `var(--token)` uses and 22 raw oklch literals, violating its own
|
||||
// "never invent raw colors" rule, then needed hundreds of lines of hand
|
||||
// carbonize to repair. The parent's context is the job, not overhead.
|
||||
assert.doesNotMatch(
|
||||
liveMd,
|
||||
/impeccable[-_]live[-_]generator/,
|
||||
'live generation must not be delegated to a subagent',
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(ROOT, 'skill/agents/impeccable-live-generator.md')),
|
||||
false,
|
||||
'the live generator agent must not come back without the context problem being solved',
|
||||
);
|
||||
// Copy edits keep their subagent: applying a known set of ops to a named file
|
||||
// is self-contained work, so an isolated context costs nothing.
|
||||
assert.match(manualAgentMd, /codex-name: impeccable_manual_edit_applier/);
|
||||
assert.match(liveMd, /live-accept\.mjs --page-url PAGE_URL/);
|
||||
assert.match(liveMd, /If `repair` is present/);
|
||||
assert.match(liveMd, /Fix the current source/);
|
||||
@@ -106,8 +129,11 @@ describe('live reference authoring contract', () => {
|
||||
|
||||
it('keeps Codex sandbox guidance Codex-only', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
const codexLiveMd = compileProviderBlocks(liveMd, ['codex']);
|
||||
const claudeLiveMd = compileProviderBlocks(liveMd, ['claude-code', 'claude']);
|
||||
// Compile with each provider's real tags rather than hand-written ones, so a
|
||||
// providers.js misconfiguration fails here instead of shipping.
|
||||
const compileFor = (provider) => compileProviderBlocks(liveMd, PROVIDERS[provider].providerTags);
|
||||
const codexLiveMd = compileFor('codex');
|
||||
const claudeLiveMd = compileFor('claude-code');
|
||||
|
||||
assert.match(
|
||||
codexLiveMd,
|
||||
@@ -131,6 +157,19 @@ describe('live reference authoring contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('routes every helper command through the per-provider scripts path', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
// A recipe that hardcodes `.agents/skills/...` is only correct for the Codex
|
||||
// repo-skills bundle. Every other harness would be told to run the helper
|
||||
// from a directory its install never creates.
|
||||
assert.doesNotMatch(
|
||||
liveMd,
|
||||
/node\s+\.[a-z-]+\/skills\/impeccable\/scripts\//,
|
||||
'live.md must not hardcode a harness config dir; use {{scripts_path}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps live preview CSS guidance capability-mode driven', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
|
||||
|
||||
+466
-1
@@ -111,6 +111,31 @@ it('gitignores local Impeccable runtime artifacts', () => {
|
||||
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
|
||||
});
|
||||
|
||||
it('Stop Live removes Nuxt Vue preview modules and their generated root', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'impeccable-live-nuxt-stop-'));
|
||||
const generatedRoot = join(cwd, 'app/.impeccable-live');
|
||||
mkdirSync(join(generatedRoot, 'session123'), { recursive: true });
|
||||
writeFileSync(join(cwd, 'nuxt.config.ts'), 'export default defineNuxtConfig({});\n');
|
||||
writeFileSync(join(generatedRoot, '__runtime.js'), 'export const runtime = true;\n');
|
||||
writeFileSync(join(generatedRoot, 'session123', 'v1.vue'), '<template><h1>Preview</h1></template>\n');
|
||||
|
||||
let live;
|
||||
try {
|
||||
live = await startServer(8498, { cwd });
|
||||
const exited = new Promise((resolve) => live.proc.once('exit', resolve));
|
||||
await stopServer(live.port, live.token);
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('live server did not stop')), 2_000)),
|
||||
]);
|
||||
assert.equal(existsSync(join(generatedRoot, '__runtime.js')), false);
|
||||
assert.equal(existsSync(generatedRoot), false);
|
||||
} finally {
|
||||
live?.proc?.kill();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function readSseUntil(reader, decoder, needle, maxReads = 12) {
|
||||
let text = '';
|
||||
for (let i = 0; i < maxReads; i++) {
|
||||
@@ -224,6 +249,40 @@ describe('live-server integration', () => {
|
||||
assert.equal(data.agentPolling, false);
|
||||
});
|
||||
|
||||
it('/status stops reporting agentPolling as soon as a poll returns an event', async () => {
|
||||
await drainPolls(server);
|
||||
const pollPromise = fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=5000&leaseMs=30000`,
|
||||
).then((response) => response.json());
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const eventRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id: 'aabbcc77',
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button>Truthful poll</button>', tagName: 'BUTTON' },
|
||||
}),
|
||||
});
|
||||
assert.equal(eventRes.status, 200);
|
||||
const event = await pollPromise;
|
||||
assert.equal(event.id, 'aabbcc77');
|
||||
|
||||
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
|
||||
assert.equal(status.agentPolling, false);
|
||||
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id: event.id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('/live.js serves script with token injected', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/live.js`);
|
||||
assert.equal(res.status, 200);
|
||||
@@ -2023,6 +2082,59 @@ colors: {}
|
||||
assert.equal(data.type, 'timeout');
|
||||
});
|
||||
|
||||
it('/poll type filters keep parallel poll consumers disjoint', async () => {
|
||||
await drainPolls(server);
|
||||
const controlPoll = fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000&types=steer,manual_edit_apply,carbonize_cleanup,exit`,
|
||||
).then((response) => response.json());
|
||||
const workerPoll = fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000&types=generate,accept,discard,prefetch`,
|
||||
).then((response) => response.json());
|
||||
|
||||
const steer = {
|
||||
token: server.token,
|
||||
type: 'steer',
|
||||
id: 'aabbcc01',
|
||||
pageUrl: '/',
|
||||
message: 'Keep this on the foreground lane',
|
||||
};
|
||||
const generate = {
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id: 'aabbcc02',
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button id="lane-test">Book</button>', id: 'lane-test', tagName: 'BUTTON' },
|
||||
};
|
||||
for (const event of [steer, generate]) {
|
||||
const response = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
|
||||
const [controlEvent, workerEvent] = await Promise.all([controlPoll, workerPoll]);
|
||||
assert.equal(controlEvent.type, 'steer');
|
||||
assert.equal(controlEvent.id, steer.id);
|
||||
assert.equal(workerEvent.type, 'generate');
|
||||
assert.equal(workerEvent.id, generate.id);
|
||||
|
||||
for (const reply of [
|
||||
{ id: steer.id, type: 'steer_done', message: 'Control lane handled it', sourceEventType: 'steer' },
|
||||
{ id: generate.id, type: 'done', sourceEventType: 'generate' },
|
||||
]) {
|
||||
const response = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, ...reply }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
it('/poll rejects invalid token', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/poll?token=wrong&timeout=100`);
|
||||
assert.equal(res.status, 401);
|
||||
@@ -2142,6 +2254,9 @@ colors: {}
|
||||
assert.equal(event.id, 'a1b2c3d4');
|
||||
assert.equal(event.action, 'bolder');
|
||||
assert.equal(event.count, 2);
|
||||
assert.equal(event.scaffoldAttempted, true);
|
||||
assert.equal(event.scaffoldError, 'insufficient_locator');
|
||||
assert.equal(Number.isFinite(event.generationReadyAt), true);
|
||||
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
@@ -2187,6 +2302,42 @@ colors: {}
|
||||
|
||||
it('accepts checkpoint events without exposing them as agent poll work', async () => {
|
||||
await drainPolls(server);
|
||||
const partialRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3d7',
|
||||
phase: 'cycling',
|
||||
reason: 'browser_resumed',
|
||||
revision: 1,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
}),
|
||||
});
|
||||
assert.equal(partialRes.status, 200);
|
||||
|
||||
const secondRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3d7',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
revision: 2,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 2,
|
||||
visibleVariant: 2,
|
||||
}),
|
||||
});
|
||||
assert.equal(secondRes.status, 200);
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2195,8 +2346,10 @@ colors: {}
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3d7',
|
||||
phase: 'cycling',
|
||||
revision: 2,
|
||||
reason: 'variants_ready',
|
||||
revision: 3,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 3,
|
||||
visibleVariant: 2,
|
||||
paramValues: { density: 'packed' },
|
||||
@@ -2214,6 +2367,148 @@ colors: {}
|
||||
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3d7.snapshot.json'), 'utf-8'));
|
||||
assert.equal(snapshot.visibleVariant, 2);
|
||||
assert.deepEqual(snapshot.paramValues, { density: 'packed' });
|
||||
assert.ok(snapshot.generationTimings.first_reviewable?.at);
|
||||
assert.ok(snapshot.generationTimings.second_reviewable?.at);
|
||||
assert.ok(snapshot.generationTimings.all_variants_ready?.at);
|
||||
assert.ok(snapshot.generationTimings.first_reviewable.at <= snapshot.generationTimings.second_reviewable.at);
|
||||
assert.ok(snapshot.generationTimings.second_reviewable.at <= snapshot.generationTimings.all_variants_ready.at);
|
||||
|
||||
const atomicRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3da',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_ready',
|
||||
revision: 1,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 3,
|
||||
visibleVariant: 1,
|
||||
}),
|
||||
});
|
||||
assert.equal(atomicRes.status, 200);
|
||||
const atomicSnapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3da.snapshot.json'), 'utf-8'));
|
||||
assert.ok(atomicSnapshot.generationTimings.first_reviewable?.at);
|
||||
assert.equal(
|
||||
atomicSnapshot.generationTimings.first_reviewable.at,
|
||||
atomicSnapshot.generationTimings.all_variants_ready?.at,
|
||||
'atomic delivery makes the first variant and full set reviewable together',
|
||||
);
|
||||
});
|
||||
|
||||
it('journals and streams agent progress without leasing it as work', async () => {
|
||||
await drainPolls(server);
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = sseRes.body.getReader();
|
||||
await reader.read();
|
||||
const progress = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'agent_phase',
|
||||
id: 'a1b2c3e1',
|
||||
phase: 'first_variant_generating',
|
||||
owner: 'live-agent',
|
||||
}),
|
||||
});
|
||||
assert.equal(progress.status, 200);
|
||||
const message = new TextDecoder().decode((await reader.read()).value);
|
||||
controller.abort();
|
||||
assert.match(message, /"type":"agent_phase"/);
|
||||
assert.match(message, /"phase":"first_variant_generating"/);
|
||||
const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json());
|
||||
assert.equal(polled.type, 'timeout');
|
||||
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3e1.snapshot.json'), 'utf-8'));
|
||||
assert.ok(snapshot.generationTimings.first_variant_generating?.at);
|
||||
});
|
||||
|
||||
it('streams Svelte component checkpoints as progressive preview updates', async () => {
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = sseRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await reader.read(); // connected
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3de',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
revision: 1,
|
||||
owner: 'svelte-worker',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
previewMode: 'svelte-component',
|
||||
previewFile: 'node_modules/.impeccable-live/a1b2c3de/manifest.json',
|
||||
sourceFile: 'src/routes/+page.svelte',
|
||||
}),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const { value } = await reader.read();
|
||||
const message = decoder.decode(value);
|
||||
assert.match(message, /"type":"variant_progress"/);
|
||||
assert.match(message, /"arrivedVariants":1/);
|
||||
assert.match(message, /"previewMode":"svelte-component"/);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
it('streams source checkpoints so no-HMR frameworks can review variant 1', async () => {
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = sseRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await reader.read(); // connected
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3df',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
revision: 1,
|
||||
owner: 'source-worker',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
previewMode: 'source',
|
||||
previewFile: 'app/pages/index.vue',
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
publicationKind: 'params',
|
||||
}),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const { value } = await reader.read();
|
||||
const message = decoder.decode(value);
|
||||
assert.match(message, /"type":"variant_progress"/);
|
||||
assert.match(message, /"arrivedVariants":1/);
|
||||
assert.match(message, /"previewMode":"source"/);
|
||||
assert.match(message, /"previewFile":"app\/pages\/index.vue"/);
|
||||
assert.match(message, /"publicationKind":"params"/);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
it('redelivers an unacknowledged browser event after helper server restart', async () => {
|
||||
@@ -2360,6 +2655,105 @@ colors: {}
|
||||
assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue');
|
||||
});
|
||||
|
||||
it('retires the leased Generate when early Accept or Discard takes ownership', async () => {
|
||||
await drainPolls(server);
|
||||
for (const [type, id] of [['accept', 'ea11ac01'], ['discard', 'ea11dc01']]) {
|
||||
const generated = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'bolder',
|
||||
count: 3,
|
||||
element: { outerHTML: '<section>early choice</section>', tagName: 'section' },
|
||||
}),
|
||||
});
|
||||
assert.equal(generated.status, 200);
|
||||
const generation = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=40`).then((response) => response.json());
|
||||
assert.equal(generation.id, id);
|
||||
|
||||
const chosen = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type,
|
||||
id,
|
||||
...(type === 'accept' ? { variantId: '1' } : {}),
|
||||
}),
|
||||
});
|
||||
assert.equal(chosen.status, 200);
|
||||
const choice = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=${type}&timeout=100&leaseMs=40`).then((response) => response.json());
|
||||
assert.equal(choice.type, type);
|
||||
assert.equal(choice.id, id);
|
||||
const reply = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
id,
|
||||
sourceEventType: type,
|
||||
type: type === 'discard' ? 'discarded' : 'complete',
|
||||
}),
|
||||
});
|
||||
assert.equal(reply.status, 200);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
const stale = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=30&leaseMs=20`).then((response) => response.json());
|
||||
assert.equal(stale.type, 'timeout', `${type} must prevent Generate redelivery after its old lease expires`);
|
||||
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
|
||||
assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('releases a failed worker Generate lease without consuming or broadcasting it', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'fa11bac1';
|
||||
const generated = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'bolder',
|
||||
count: 3,
|
||||
element: { outerHTML: '<article>fallback</article>', tagName: 'article' },
|
||||
}),
|
||||
});
|
||||
assert.equal(generated.status, 200);
|
||||
const leased = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=5000`).then((response) => response.json());
|
||||
assert.equal(leased.id, id);
|
||||
|
||||
const retried = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
id,
|
||||
type: 'retry',
|
||||
sourceEventType: 'generate',
|
||||
}),
|
||||
});
|
||||
assert.equal(retried.status, 200);
|
||||
assert.equal((await retried.json()).released, true);
|
||||
|
||||
const fallback = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=100`).then((response) => response.json());
|
||||
assert.equal(fallback.id, id);
|
||||
assert.equal(fallback.type, 'generate');
|
||||
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
|
||||
assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), true);
|
||||
|
||||
const done = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
assert.equal(done.status, 200);
|
||||
});
|
||||
|
||||
it('wakes a parked poll as soon as a missed-ack lease expires', async () => {
|
||||
await drainPolls(server);
|
||||
|
||||
@@ -2690,4 +3084,75 @@ colors: {}
|
||||
const data = await postRes.json();
|
||||
assert.match(data.error, /freeformPrompt or annotations/i);
|
||||
});
|
||||
|
||||
// A stale generate worker's `error` reply used to acknowledge *any* pending
|
||||
// event for its id, because inferSourceEventType returned undefined and
|
||||
// acknowledgePendingEvent treats that as a wildcard. It ate the user's queued
|
||||
// Accept, which was then never handed to an agent: the browser sat in SAVING
|
||||
// forever and a restart could not requeue it.
|
||||
it('a stale generate error reply does not consume a queued accept', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'ee55ff66';
|
||||
|
||||
await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button>Book</button>' },
|
||||
}),
|
||||
});
|
||||
|
||||
// Agent leases the generate.
|
||||
const leased = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=200&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(leased.id, id);
|
||||
assert.equal(leased.type, 'generate');
|
||||
|
||||
// User accepts. This retires the pending generate and queues the accept.
|
||||
await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, type: 'accept', id, variantId: '1' }),
|
||||
});
|
||||
|
||||
// The stale generate worker now fails, using live.md's documented reply.
|
||||
const errRes = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'error', message: 'late failure' }),
|
||||
});
|
||||
assert.equal(errRes.status, 200);
|
||||
|
||||
const status = await (await fetch(
|
||||
`http://localhost:${server.port}/status?token=${server.token}`,
|
||||
)).json();
|
||||
assert.equal(
|
||||
status.pendingEvents.some((e) => e.id === id && e.type === 'accept'),
|
||||
true,
|
||||
'the queued accept must survive a stale generate error',
|
||||
);
|
||||
|
||||
// And it must still be deliverable to the next agent that polls.
|
||||
const next = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=30000`,
|
||||
)).json();
|
||||
assert.equal(next.id, id);
|
||||
assert.equal(next.type, 'accept', 'the accept must reach an agent');
|
||||
|
||||
// Acknowledge the accept explicitly. drainPolls replies `done`, which maps
|
||||
// to `generate`, so it can never retire an accept and would re-lease it in
|
||||
// a loop forever.
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'complete', sourceEventType: 'accept' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,69 @@ describe('live-session-store', () => {
|
||||
assert.equal(active[0].id, 'session-a');
|
||||
});
|
||||
|
||||
it('persists the progressive variant plan across worker restarts', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
|
||||
const plan = {
|
||||
identityLock: ['Preserve copy'],
|
||||
directions: [
|
||||
{ variantId: 1, name: 'Hierarchy', axis: 'scale', intent: 'Increase hierarchy' },
|
||||
{ variantId: 2, name: 'Composition', axis: 'layout', intent: 'Recompose the root' },
|
||||
{ variantId: 3, name: 'Rhythm', axis: 'spacing', intent: 'Increase rhythm' },
|
||||
],
|
||||
};
|
||||
store.appendEvent({ type: 'generate', id: 'planned-session', count: 3 });
|
||||
store.appendEvent({ type: 'variant_plan', id: 'planned-session', plan });
|
||||
store.appendEvent({ type: 'checkpoint', id: 'planned-session', revision: 1, arrivedVariants: 1 });
|
||||
|
||||
const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
|
||||
assert.deepEqual(restarted.getSnapshot('planned-session').variantPlan, plan);
|
||||
});
|
||||
|
||||
it('tombstones generation on early accept and ignores late generation writes', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'early-accept',
|
||||
action: 'polish',
|
||||
count: 3,
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'checkpoint',
|
||||
id: 'early-accept',
|
||||
revision: 1,
|
||||
phase: 'cycling',
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
});
|
||||
store.appendEvent({ type: 'accept', id: 'early-accept', variantId: '1' });
|
||||
store.appendEvent({
|
||||
type: 'checkpoint',
|
||||
id: 'early-accept',
|
||||
revision: 2,
|
||||
phase: 'variants_ready',
|
||||
arrivedVariants: 3,
|
||||
visibleVariant: 3,
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'agent_done',
|
||||
id: 'early-accept',
|
||||
file: 'src/App.jsx',
|
||||
arrivedVariants: 3,
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot('early-accept');
|
||||
assert.equal(snapshot.phase, 'accept_requested');
|
||||
assert.equal(snapshot.generationCanceled, true);
|
||||
assert.equal(snapshot.cancelReason, 'accept');
|
||||
assert.equal(snapshot.arrivedVariants, 1);
|
||||
assert.equal(snapshot.visibleVariant, 1);
|
||||
assert.equal(
|
||||
snapshot.diagnostics.some((entry) => entry.error === 'late_generation_event_ignored'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports corrupted journal lines while preserving valid prior events', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' });
|
||||
store.appendEvent({
|
||||
@@ -161,6 +224,30 @@ describe('live-session-store', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks publication and browser checkpoint revisions independently', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'split-revisions' });
|
||||
store.appendEvent({
|
||||
type: 'generate', id: 'split-revisions', count: 3,
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'checkpoint', id: 'split-revisions', revision: 8, revisionDomain: 'browser',
|
||||
owner: 'browser-a', phase: 'cycling', visibleVariant: 2,
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'checkpoint', id: 'split-revisions', revision: 3, revisionDomain: 'publication',
|
||||
reason: 'variants_progress', phase: 'cycling', arrivedVariants: 3,
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot('split-revisions');
|
||||
assert.equal(snapshot.browserCheckpointRevision, 8);
|
||||
assert.equal(snapshot.checkpointRevision, 8);
|
||||
assert.equal(snapshot.publicationCheckpointRevision, 3);
|
||||
assert.equal(snapshot.visibleVariant, 2);
|
||||
assert.equal(snapshot.arrivedVariants, 3);
|
||||
assert.equal(snapshot.diagnostics.some((entry) => entry.error === 'stale_checkpoint_ignored'), false);
|
||||
});
|
||||
|
||||
it('keeps carbonize-required accepted sessions active until explicit completion', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'carbonize-session' });
|
||||
store.appendEvent({
|
||||
@@ -284,4 +371,26 @@ describe('live-session-store', () => {
|
||||
assert.equal(migratedSnapshot.expectedVariants, 2);
|
||||
assert.equal(migratedSnapshot.sourceFile, 'src/App.jsx');
|
||||
});
|
||||
|
||||
it('records generation phase timings without replacing the workflow phase', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'phase-session' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'phase-session',
|
||||
count: 3,
|
||||
element: { classes: ['hero'] },
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'agent_phase',
|
||||
id: 'phase-session',
|
||||
phase: 'source_ready',
|
||||
at: 1234,
|
||||
durationMs: 42,
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot('phase-session');
|
||||
assert.equal(snapshot.phase, 'generate_requested');
|
||||
assert.equal(snapshot.generationPhase, 'source_ready');
|
||||
assert.deepEqual(snapshot.generationTimings.source_ready, { at: 1234, durationMs: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Tests for live/source-lock.mjs — the per-source-file mutex guarding the
|
||||
* accept/publish critical sections.
|
||||
* Run with: node --test tests/live-source-lock.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { sourceLockPath, withSourceLockSync } from '../skill/scripts/live/source-lock.mjs';
|
||||
|
||||
const TARGET = 'src/page.html';
|
||||
|
||||
describe('live source-lock', () => {
|
||||
let tmp;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-source-lock-'));
|
||||
mkdirSync(join(tmp, 'src'), { recursive: true });
|
||||
writeFileSync(join(tmp, TARGET), '<div>original</div>\n');
|
||||
});
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const writeLock = (body) => {
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
mkdirSync(dirname(lockPath), { recursive: true });
|
||||
writeFileSync(lockPath, JSON.stringify(body) + '\n');
|
||||
return lockPath;
|
||||
};
|
||||
|
||||
it('runs the critical section and releases the lock', () => {
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
const result = withSourceLockSync(TARGET, 'accept:a', () => {
|
||||
assert.equal(existsSync(lockPath), true, 'lock must exist while held');
|
||||
return 'done';
|
||||
}, { cwd: tmp });
|
||||
assert.equal(result, 'done');
|
||||
assert.equal(existsSync(lockPath), false, 'lock must be released');
|
||||
});
|
||||
|
||||
it('throws SOURCE_LOCKED when a live owner holds the lock', () => {
|
||||
// process.pid is this very process, so the recorded owner is alive.
|
||||
writeLock({ owner: 'publish:x', token: 'other', pid: process.pid, at: Date.now() });
|
||||
assert.throws(
|
||||
() => withSourceLockSync(TARGET, 'accept:a', () => 'should not run', { cwd: tmp }),
|
||||
(err) => err.code === 'SOURCE_LOCKED',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not sweep a live owner’s lock no matter how old it is', () => {
|
||||
// Age alone must not make a lock stale: a holder suspended mid-write would
|
||||
// otherwise have a second writer admitted to the same source file.
|
||||
const lockPath = writeLock({ owner: 'publish:x', token: 'other', pid: process.pid, at: 0 });
|
||||
const ancient = new Date(Date.now() - 10 * 60_000);
|
||||
utimesSync(lockPath, ancient, ancient);
|
||||
assert.throws(
|
||||
() => withSourceLockSync(TARGET, 'accept:a', () => 'should not run', { cwd: tmp }),
|
||||
(err) => err.code === 'SOURCE_LOCKED',
|
||||
'an old but live lock was stolen',
|
||||
);
|
||||
});
|
||||
|
||||
it('reclaims a lock whose owner process is gone, without waiting out a timeout', () => {
|
||||
// PID 2^22 is above the platform maximum, so it can never be running.
|
||||
writeLock({ owner: 'publish:crashed', token: 'other', pid: 4194304, at: Date.now() });
|
||||
const result = withSourceLockSync(TARGET, 'accept:a', () => 'acquired', { cwd: tmp });
|
||||
assert.equal(result, 'acquired', 'a crashed holder must not block the next writer');
|
||||
});
|
||||
|
||||
it('leaves a replacement lock alone when its own was swept', () => {
|
||||
// Simulates: our lock got reclaimed and another writer now owns the file.
|
||||
// Releasing must not unlink the replacement and admit a third writer.
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
withSourceLockSync(TARGET, 'accept:a', () => {
|
||||
writeFileSync(lockPath, JSON.stringify({
|
||||
owner: 'publish:other', token: 'a-different-token', pid: process.pid, at: Date.now(),
|
||||
}) + '\n');
|
||||
}, { cwd: tmp });
|
||||
assert.equal(existsSync(lockPath), true, 'another owner’s lock must survive our release');
|
||||
assert.match(readFileSync(lockPath, 'utf-8'), /a-different-token/);
|
||||
});
|
||||
|
||||
it('retires an unreadable lock only once it is older than the fallback window', () => {
|
||||
const lockPath = writeLock('');
|
||||
assert.throws(
|
||||
() => withSourceLockSync(TARGET, 'accept:a', () => 'x', { cwd: tmp }),
|
||||
(err) => err.code === 'SOURCE_LOCKED',
|
||||
'a fresh unreadable lock is an in-flight acquisition, not garbage',
|
||||
);
|
||||
const ancient = new Date(Date.now() - 120_000);
|
||||
utimesSync(lockPath, ancient, ancient);
|
||||
assert.equal(
|
||||
withSourceLockSync(TARGET, 'accept:a', () => 'acquired', { cwd: tmp }),
|
||||
'acquired',
|
||||
'a stale unreadable lock must be retired',
|
||||
);
|
||||
});
|
||||
|
||||
it('releases the lock even when the critical section throws', () => {
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
assert.throws(() => withSourceLockSync(TARGET, 'accept:a', () => {
|
||||
throw new Error('boom');
|
||||
}, { cwd: tmp }), /boom/);
|
||||
assert.equal(existsSync(lockPath), false, 'a thrown critical section must not leak the lock');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
|
||||
import {
|
||||
inlineVueComponentAccept,
|
||||
nuxtViteFsModulePath,
|
||||
removeAllVueComponentSessions,
|
||||
scaffoldVueComponentSession,
|
||||
} from '../skill/scripts/live/vue-component.mjs';
|
||||
|
||||
describe('Nuxt Vue component preview', () => {
|
||||
let tmp;
|
||||
let source;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-vue-component-'));
|
||||
source = join(tmp, 'app', 'pages', 'index.vue');
|
||||
mkdirSync(join(tmp, 'app', 'pages'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), 'export default defineNuxtConfig({ ssr: false });\n');
|
||||
writeFileSync(source, [
|
||||
'<template>',
|
||||
' <main>',
|
||||
' <h1 class="hero-title">Hello {{ user.name }}</h1>',
|
||||
' </main>',
|
||||
'</template>',
|
||||
'',
|
||||
'<style scoped>',
|
||||
'.hero-title { font-size: 2rem; }',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
it('stages real Vue SFCs without rewriting the active route', () => {
|
||||
const before = readFileSync(source, 'utf-8');
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 3,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(readFileSync(source, 'utf-8'), before);
|
||||
assert.equal(result.manifest.previewMode, 'vue-component');
|
||||
assert.equal(result.manifest.componentExtension, 'vue');
|
||||
assert.match(result.manifestFile, /^app\/\.impeccable-live\/vue12345\/manifest\.json$/);
|
||||
const variant = readFileSync(join(tmp, result.componentDir, 'v1.vue'), 'utf-8');
|
||||
assert.match(variant, /<template>/);
|
||||
assert.match(variant, /Hello \{\{ name \}\}/);
|
||||
assert.equal(existsSync(join(tmp, 'app/.impeccable-live/__runtime.js')), true);
|
||||
assert.equal(
|
||||
result.manifest.runtimeModule,
|
||||
nuxtViteFsModulePath(join(tmp, 'app/.impeccable-live/__runtime.js'), tmp),
|
||||
);
|
||||
assert.equal(
|
||||
result.manifest.componentModuleBase,
|
||||
nuxtViteFsModulePath(join(tmp, result.componentDir), tmp),
|
||||
);
|
||||
assert.match(result.manifest.runtimeModule, /^\/@fs\//);
|
||||
assert.doesNotMatch(result.manifest.runtimeModule, /^\/app\//);
|
||||
assert.match(result.manifest.componentModuleBase, /^\/@fs\//);
|
||||
});
|
||||
|
||||
it('keeps Vite module URLs valid for literal Nuxt srcDir projects', () => {
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), "export default defineNuxtConfig({ srcDir: 'client/' });\n");
|
||||
const clientSource = join(tmp, 'client', 'pages', 'index.vue');
|
||||
mkdirSync(join(tmp, 'client', 'pages'), { recursive: true });
|
||||
writeFileSync(clientSource, '<template><h1>Client app</h1></template>\n');
|
||||
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'clientsrc',
|
||||
count: 1,
|
||||
sourceFile: 'client/pages/index.vue',
|
||||
sourceStartLine: 1,
|
||||
sourceEndLine: 1,
|
||||
originalLines: ['<h1>Client app</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.match(result.manifestFile, /^client\/\.impeccable-live\/clientsrc\/manifest\.json$/);
|
||||
assert.match(result.manifest.runtimeModule, /^\/@fs\/.*\/client\/\.impeccable-live\/__runtime\.js$/);
|
||||
assert.match(result.manifest.componentModuleBase, /^\/@fs\/.*\/client\/\.impeccable-live\/clientsrc$/);
|
||||
});
|
||||
|
||||
it('accepts one generated SFC into clean Vue source and restores route expressions', () => {
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 3,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
|
||||
'<script setup>',
|
||||
"defineProps({ name: { default: '' } });",
|
||||
'</script>',
|
||||
'<template>',
|
||||
' <h1 class="hero-title variant-one">Welcome {{ name }}</h1>',
|
||||
'</template>',
|
||||
'<style scoped>',
|
||||
'.variant-one { letter-spacing: 0.02em; }',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const accepted = inlineVueComponentAccept(result.manifest, 1, tmp);
|
||||
assert.equal(accepted.handled, true);
|
||||
const next = readFileSync(source, 'utf-8');
|
||||
assert.match(next, /Welcome \{\{ user\.name \}\}/);
|
||||
assert.match(next, /class="hero-title variant-one"|class="variant-one hero-title"/);
|
||||
assert.match(next, /\.variant-one \{ letter-spacing: 0\.02em; \}/);
|
||||
assert.doesNotMatch(next, /data-impeccable/);
|
||||
assert.equal(existsSync(join(tmp, result.componentDir, 'manifest.json')), false);
|
||||
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true, 'imported SFC remains until Live shutdown');
|
||||
});
|
||||
|
||||
it('preserves original root directives and valueless attrs a variant omits', () => {
|
||||
const originalRoot = ' <button class="cta" @click="submit" :aria-label="label" v-bind:title="tip" disabled v-cloak>Go</button>';
|
||||
writeFileSync(source, [
|
||||
'<template>',
|
||||
' <main>',
|
||||
originalRoot,
|
||||
' </main>',
|
||||
'</template>',
|
||||
'',
|
||||
].join('\n'));
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 1,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [originalRoot],
|
||||
cwd: tmp,
|
||||
});
|
||||
// A restyle variant that keeps only class: every behavior attribute must survive Accept.
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
|
||||
'<template>',
|
||||
' <button class="cta cta--bold">Go</button>',
|
||||
'</template>',
|
||||
'<style scoped>',
|
||||
'.cta--bold { font-weight: 700; }',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
assert.equal(inlineVueComponentAccept(result.manifest, 1, tmp).handled, true);
|
||||
const next = readFileSync(source, 'utf-8');
|
||||
assert.match(next, /@click="submit"/, 'v-on shorthand must not degrade to a literal click attribute');
|
||||
assert.doesNotMatch(next, /\sclick="submit"/, 'sigil-stripped event handler leaked into source');
|
||||
assert.match(next, /:aria-label="label"/);
|
||||
assert.match(next, /v-bind:title="tip"/);
|
||||
assert.match(next, /\bdisabled\b/, 'valueless boolean attr dropped');
|
||||
assert.match(next, /\bv-cloak\b/, 'valueless directive dropped');
|
||||
assert.match(next, /class="cta cta--bold"|class="cta--bold cta"/);
|
||||
});
|
||||
|
||||
it('does not duplicate an attribute the variant wrote in the other shorthand form', () => {
|
||||
const originalRoot = ' <button class="cta" :aria-label="label">Go</button>';
|
||||
writeFileSync(source, ['<template>', ' <main>', originalRoot, ' </main>', '</template>', ''].join('\n'));
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 1,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [originalRoot],
|
||||
cwd: tmp,
|
||||
});
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
|
||||
'<template>',
|
||||
' <button class="cta" v-bind:aria-label="label">Go</button>',
|
||||
'</template>',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
assert.equal(inlineVueComponentAccept(result.manifest, 1, tmp).handled, true);
|
||||
const next = readFileSync(source, 'utf-8');
|
||||
assert.doesNotMatch(next, /:aria-label="label"[^>]*v-bind:aria-label|v-bind:aria-label="label"[^>]*:aria-label/,
|
||||
'shorthand and longhand of one attr both emitted, which is a Vue compile error');
|
||||
});
|
||||
|
||||
it('removes deferred SFCs, the shared runtime, and the generated root on Live shutdown', () => {
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 1,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
inlineVueComponentAccept(result.manifest, 1, tmp);
|
||||
const root = join(tmp, 'app/.impeccable-live');
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), true);
|
||||
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true);
|
||||
|
||||
removeAllVueComponentSessions(tmp);
|
||||
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), false);
|
||||
assert.equal(existsSync(root), false);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -6,9 +6,9 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
buildSearchQueries,
|
||||
@@ -253,6 +253,7 @@ describe('wrapCli integration', () => {
|
||||
assert.ok(!modified.includes('data-impeccable-variant="original" style="display: none"'));
|
||||
});
|
||||
|
||||
|
||||
it('wraps a JSX element and uses JSX comment syntax', () => {
|
||||
const jsx = `export default function App() {
|
||||
return (
|
||||
@@ -780,6 +781,34 @@ export default function App() {
|
||||
assert.ok(modified.includes('data-impeccable-variants="dyn1"'), 'wrapped (first-match fallback)');
|
||||
});
|
||||
|
||||
it('refuses multiple dynamic source branches when rendered text cannot identify one', () => {
|
||||
const astro = `---
|
||||
const results = [{ title: 'Result 01' }, { title: 'Result 02' }];
|
||||
---
|
||||
<main>
|
||||
<article class="result-card"><h2>{results[0].title}</h2></article>
|
||||
<article class="result-card"><h2>{results[1].title}</h2></article>
|
||||
</main>`;
|
||||
const file = join(tmp, 'Results.astro');
|
||||
writeFileSync(file, astro);
|
||||
|
||||
let errPayload;
|
||||
try {
|
||||
execSync(
|
||||
`node skill/scripts/live-wrap.mjs --id dyn2 --count 3 --classes "result-card" --tag "article" --text "Result 02 rendered body" --file "${file}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
assert.fail('Should have refused an unsafe first-match fallback');
|
||||
} catch (err) {
|
||||
errPayload = JSON.parse(err.stderr.toString().trim());
|
||||
}
|
||||
|
||||
assert.equal(errPayload.error, 'element_ambiguous');
|
||||
assert.equal(errPayload.reason, 'rendered_text_not_in_source');
|
||||
assert.equal(errPayload.candidates.length, 2);
|
||||
assert.doesNotMatch(readFileSync(file, 'utf-8'), /impeccable-variants-start/);
|
||||
});
|
||||
|
||||
it('errors with element_ambiguous when --text matches multiple identical branches', () => {
|
||||
// Two <aside className="card"> with truly identical body text. --text
|
||||
// can't pick a winner — wrap should refuse rather than silently land.
|
||||
|
||||
Reference in New Issue
Block a user