From 3600edc5e9d56eaab7fb79d4cbf8a4dd104d2285 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 18 Jul 2026 14:11:07 -0700 Subject: [PATCH] Live: polling rework, source locks, preflight scaffolding, Vue previews Carved out of #371, minus progressive publication. Everything here works against real project source the way main's Live already does: the agent writes variants into the file the browser loaded, HMR fires, Accept promotes and carbonizes. Nothing is staged anywhere. Poll lanes. Events now carry an explicit priority: accept/discard/exit ahead of manual_edit_apply/steer/carbonize_cleanup ahead of generate. A long generate can no longer sit in front of the Accept the user just clicked. leaseEvent claims its lease before awaiting, so a slow prepare cannot hand the same event to two pollers. Source locks. A per-file mutex around every accept and discard path, keyed on a digest of the absolute path. Staleness is decided by owner-pid liveness rather than mtime, so a wedged lock clears when its owner dies instead of after an arbitrary timeout, and a slow-but-live accept is never stolen from. Only the owning process can release a lock. Preflight scaffolding. The server runs live-wrap (or live-insert) before the poll returns and hands the result back as event.scaffold. That walk is measured at ~7.6s on a large repo; moving it off the agent's critical path removes a deterministic tool round trip without touching the generated design. Falls back cleanly to the agent running the helper itself. Vue previews. previewMode: "vue-component" for Nuxt/Vue targets, matching the existing Svelte component path: variants compile as real SFCs from a dev-only directory so the route is never rewritten during generation, and Vite mounts them without invalidating page state. Accept is the only route write. Includes a Vue attr tokenizer that normalizes shorthand bindings (@x, :x, #x) to their canonical forms. Accept hardening. Every thrown failure now returns mode: 'error' rather than an ambiguous unhandled result, so a real failure is never classified as a deliberate manual handoff and silently dropped. The marker search skips node_modules/.git/dist/build/.impeccable. Shared CLI arg parsing extracted to scripts/lib/cli-args.mjs. Assisted-by: Claude Code --- .impeccable/config.json | 9 - AGENTS.md | 2 - CLAUDE.md | 6 +- cli/bin/commands/ignores.mjs | 4 +- cli/engine/engines/regex/detect-text.mjs | 142 +--- cli/lib/impeccable-config.mjs | 10 +- scripts/benchmark-live-control.mjs | 90 --- scripts/benchmark-live-init.mjs | 100 --- scripts/compare-live-benchmarks.mjs | 39 -- scripts/lib/transformers/providers.js | 9 +- scripts/lib/utils.js | 5 - scripts/test-suites.mjs | 1 - skill/reference/hooks.md | 17 +- skill/reference/live.md | 31 - skill/scripts/hook-admin.mjs | 41 +- skill/scripts/hook-lib.mjs | 20 +- skill/scripts/live-accept.mjs | 5 - skill/scripts/live-browser.js | 1 - skill/scripts/live-publish.mjs | 37 -- skill/scripts/live-server.mjs | 1 - skill/scripts/live/generation-publisher.mjs | 604 ------------------ skill/scripts/live/session-store.mjs | 40 -- tests/detect-antipatterns-fixtures.test.mjs | 61 -- .../astro-inset-shadow-stripe.astro | 88 --- tests/hook.test.mjs | 128 ---- tests/live-accept.test.mjs | 10 - tests/live-e2e.test.mjs | 318 --------- tests/live-e2e/agent.mjs | 234 +------ tests/live-e2e/session.mjs | 12 +- tests/live-generation-publisher.test.mjs | 387 ----------- tests/live-reference.test.mjs | 50 +- tests/live-session-store.test.mjs | 15 - tests/live-vue-component.test.mjs | 75 --- 33 files changed, 62 insertions(+), 2530 deletions(-) delete mode 100644 scripts/benchmark-live-control.mjs delete mode 100644 scripts/benchmark-live-init.mjs delete mode 100644 scripts/compare-live-benchmarks.mjs delete mode 100644 skill/scripts/live-publish.mjs delete mode 100644 skill/scripts/live/generation-publisher.mjs delete mode 100644 tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro delete mode 100644 tests/live-generation-publisher.test.mjs diff --git a/.impeccable/config.json b/.impeccable/config.json index b2df55f0d..45ca0e911 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -71,15 +71,6 @@ ], "createdAt": "2026-06-15T23:37:38.170Z", "reason": "Generic slop card intentionally uses Inter for the before-state comparison" - }, - { - "rule": "design-system-font-size", - "value": "*", - "files": [ - "skill/scripts/live-browser.js" - ], - "createdAt": "2026-07-17T00:00:00.000Z", - "reason": "Live overlay chrome is injected over arbitrary host pages and builds a self-contained UI with its own small type scale; DESIGN.md's ramp describes the impeccable website, not this widget" } ] }, diff --git a/AGENTS.md b/AGENTS.md index 123b231b1..47ac85aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,6 @@ Conventions: wrap the identifying heading text in straight double quotes inside Recent history favors short, imperative subjects such as `Fix: ...`, `Add ...`, `Improve ...`, or `Bump ...`. Keep commits focused and explain the user-facing impact when it is not obvious. PRs should summarize what changed, list validation performed, and call out whether generated provider output was intentionally omitted or intentionally refreshed. Include screenshots for visible `site/` changes and mention affected providers when transform behavior changes. -**Do not bump manifest versions or add changelog entries in a feature PR.** Bumping is a release step: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened yet. Land the code; the maintainer bumps `package.json` / `.claude-plugin/plugin.json` / `extension/manifest.json` and writes `site/pages/changelog.astro` when cutting the release (see **Releases**). The only PR that touches a manifest version is one whose purpose is the release itself. - ## Contributing, Issue, and PR Guidelines This repo is issue-first for outside contributions. If you are not `pbakaus` or `abdulwahabone`, do not open a PR unless a maintainer has first discussed the change in an issue and asked for, or clearly approved, a PR. For unapproved work, open an issue or comment on an existing issue and wait for maintainer direction. Unsolicited PRs may be closed without review. diff --git a/CLAUDE.md b/CLAUDE.md index 89f804d29..c1b6fd3aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,9 +68,7 @@ Editorial brief is at `docs/STYLE.md`. Read it before editing the homepage, sub- The build's `validateProse` step (in `scripts/build.js`) enforces a denylist: em dashes (`—` and HTML entities), the `--` em-dash substitute, `load-bearing`, `highest-leverage`, `biggest unlock`, `seamless`, `robust`, `delve`, `elevate`, `empower`, `underscore`, `pivotal`, `tapestry`, `data-driven`, `reflex defaults`, `collapses into monoculture`, `in today's`, `gone are the days`, `whether you're`, `let's dive in`, `in summary`, `in conclusion`, `moreover`, `furthermore`. Each rule prints a rationale and a suggested replacement when it fires. **Do not silently work around the regex.** If a banned word has earned a real meaning here, raise it as a `docs/STYLE.md` amendment. -`validateProse` scans `site/components/`, `site/content/`, `site/layouts/`, `site/pages/`, `README.md`, `README.npm.md` (extensions `.html`, `.md`, `.js`, `.mjs`, `.css`, `.astro`). It exempts `site/pages/slop/`, because the slop catalog documents every anti-pattern by example and has to contain the specimens. - -**`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not `skill/scripts/**` code or comments) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `skill/scripts/*.mjs` code comment does not. +The validator scans `site/pages/`, `site/content/`, `site/components/`, `site/layouts/`, `README.md`, `README.npm.md`. It deliberately skips `skill/` because LLM-facing reference instructions sometimes need technical phrasings the marketing copy can't. The deeper structural issues (negation pivot, triadic auto-pilot, uniform paragraph rhythm, hollow confidence) require human judgment. `docs/STYLE.md` lists them. Use them on every editorial pass. @@ -250,8 +248,6 @@ bun run build:browser ## Versioning -**Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself. - There are three independently versioned components. Only bump the one(s) that actually changed: **CLI** (npm package): diff --git a/cli/bin/commands/ignores.mjs b/cli/bin/commands/ignores.mjs index 0d1393d0f..9eb4696ed 100644 --- a/cli/bin/commands/ignores.mjs +++ b/cli/bin/commands/ignores.mjs @@ -226,14 +226,12 @@ function addValue(cwd, args) { if (parsed.reason) existing.reason = parsed.reason; if (parsed.files.length) existing.files = parsed.files; } else { - // rule, value, files, createdAt, reason — the same order the normalizers emit, - // so a fresh entry survives the next write untouched. const entry = { rule: parsed.rule, value: parsed.value, + createdAt: new Date().toISOString(), }; if (parsed.files.length) entry.files = parsed.files; - entry.createdAt = new Date().toISOString(); if (parsed.reason) entry.reason = parsed.reason; config.ignoreValues.push(entry); } diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 02d63eac3..1affdda43 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -43,48 +43,23 @@ function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } -// CSS named colors whose channels are equal (achromatic). Anything outside -// this set falls through to the format parsers, and an unrecognized spelling -// stays non-neutral so a real accent is never skipped. -const NEUTRAL_COLOR_KEYWORDS = new Set([ - 'transparent', 'currentcolor', - 'black', 'white', 'gray', 'grey', 'silver', - 'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey', - 'gainsboro', 'whitesmoke', -]); - -function hexChannels(color) { - const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i); - if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)]; - const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i); - if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16)); - return null; -} - -/** - * Neutrality test for colors as written in source CSS. - * - * shared/color.mjs's isNeutralColor only parses the computed function forms a - * browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every - * other spelling as chromatic so an unknown format is never silently skipped. - * That default is wrong for authored CSS, where `#000` and `black` are the - * normal spellings: calling it directly reports a plain black hairline as a - * colored stripe. Handle hex and named neutrals here, then defer. - */ -function isNeutralAuthoredColor(rawColor) { - const c = String(rawColor || '').trim().toLowerCase(); - if (!c) return false; - if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true; - if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); - const channels = hexChannels(c); - if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30; - return false; -} - function isNeutralBorderColor(str) { const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i); if (!m) return false; - return isNeutralAuthoredColor(m[1]); + const c = m[1].toLowerCase(); + if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; + if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); + const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); + if (hex) { + const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]; + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; + } + const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); + if (shex) { + const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)]; + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; + } + return false; } const REGEX_MATCHERS = [ @@ -370,92 +345,12 @@ const REGEX_ANALYZERS = [ ]; // --------------------------------------------------------------------------- -// Structural CSS checks used by source files whose styles are not parsed by -// the static HTML engine. -// --------------------------------------------------------------------------- - -const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i; - -function insetStripeColorIsChromatic(rawColor) { - const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, ''); - if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false; - const variable = color.match(/^var\(\s*(--[\w-]+)/i); - if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]); - if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false; - return !isNeutralAuthoredColor(color); -} - -/** - * Blank out comment bodies while preserving every byte offset (and therefore - * every line number) so commented-out CSS is not scanned as live rules. - */ -function blankCssComments(css) { - return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')); -} - -function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) { - const content = blankCssComments(rawContent); - const findings = []; - const ruleRe = /([^{};]+)\{([^{}]*)\}/g; - let match; - while ((match = ruleRe.exec(content)) !== null) { - // The selector group is `[^{};]+`, which greedily absorbs the whitespace and - // newlines trailing the previous rule. Advance past that run before deriving - // the line, or every rule after the first reports the preceding line. - const selectorStart = match.index + (match[1].length - match[1].trimStart().length); - const selector = match[1].trim().replace(/\s+/g, ' '); - if (!selector) continue; - if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue; - if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue; - if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue; - if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue; - if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue; - - const width = match[2].match(/(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/i); - if (width && Number(width[1]) <= 40) continue; - const declaration = match[2].match(/(?:^|;)\s*box-shadow\s*:\s*([^;]+)/i); - if (!declaration || !/\binset\b/i.test(declaration[1])) continue; - - for (const rawLayer of declaration[1].split(/,(?![^(]*\))/)) { - const layer = rawLayer.trim(); - // `inset` is order-independent inside a box-shadow layer: `inset 4px 0 0 red` - // and `4px 0 0 red inset` paint the same stripe, and requiring it first - // silently missed the second spelling. Strip it only as a standalone - // keyword, so a color token such as var(--inset-accent) survives intact; - // an unchanged layer had no inset keyword and is not our shape. - const body = layer.replace(/(^|\s)inset(?=\s|$)/i, '$1').trim(); - if (body === layer) continue; - // box-shadow takes {2,4}: only the two offsets are required, so - // `inset 4px 0 red` is valid and paints the same stripe as - // `inset 4px 0 0 red`. Demanding a third length missed the short form. - const shadow = body.match(/^(-?\d*\.?\d+)(px)?\s+(-?\d*\.?\d+)(px)?(?:\s+(-?\d*\.?\d+)(px)?)?(?:\s+(-?\d*\.?\d+)(px)?)?\s+(.+)$/i); - if (!shadow) continue; - const x = Number(shadow[1]); - const y = Number(shadow[3]); - // Omitted blur and spread default to 0, which is exactly the stripe shape. - const blur = shadow[5] == null ? 0 : Number(shadow[5]); - const spread = shadow[7] == null ? 0 : Number(shadow[7]); - if ((x !== 0 && !shadow[2]) || (y !== 0 && !shadow[4]) || blur !== 0 || spread !== 0) continue; - const ax = Math.abs(x); - const ay = Math.abs(y); - if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue; - if (!insetStripeColorIsChromatic(shadow[9])) continue; - const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom'); - const line = lineOffset + content.slice(0, selectorStart).split('\n').length; - findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line)); - break; - } - } - return findings; -} - -// --------------------------------------------------------------------------- -// Style block extraction (Astro/Vue/Svelte ', start); - if (end < 0) return ''; - return source.slice(start, end) - .replace(/^\s*\{\s*`\s*/, '') - .replace(/\s*`\s*\}\s*$/, '') - .trim(); -} - -function atomicReplace(target, content) { - let mode = 0o666; - try { mode = fs.statSync(target).mode; } catch {} - const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now(); - try { - fs.writeFileSync(temp, content, { encoding: 'utf-8', mode }); - fs.renameSync(temp, target); - } finally { - try { fs.unlinkSync(temp); } catch {} - } -} - -function resolveInside(cwd, value) { - const resolved = path.resolve(cwd, value); - const rel = path.relative(cwd, resolved); - if (rel.startsWith('..') || path.isAbsolute(rel)) return null; - return resolved; -} - -function relative(cwd, value) { - return path.relative(cwd, value).split(path.sep).join('/'); -} - -function failure(error, details = {}) { - return { ok: false, error, ...details }; -} - -/** - * The generation fence: has this session been canceled (Accept/Discard landed), - * or has a newer generation superseded this epoch? Returns a failure result to - * propagate, or null when the caller may proceed. - */ -function staleGenerationFailure(snapshot, epoch) { - if (snapshot?.generationCanceled === true) { - return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase }); - } - if (Number(snapshot?.generationEpoch || 1) !== epoch) { - return failure('stale_generation_epoch', { expectedEpoch: snapshot?.generationEpoch || 1 }); - } - return null; -} diff --git a/skill/scripts/live/session-store.mjs b/skill/scripts/live/session-store.mjs index 40a81c3d3..8253ab3f8 100644 --- a/skill/scripts/live/session-store.mjs +++ b/skill/scripts/live/session-store.mjs @@ -121,11 +121,7 @@ function baseSnapshot(id) { fallbackMode: null, generationPhase: null, generationTimings: {}, - generationEpoch: 1, - publishedRevision: 0, - deliveredVariants: {}, variantPlan: null, - paramsPublished: false, generationCanceled: false, generationCanceledAt: null, cancelReason: null, @@ -169,7 +165,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { paramValues: { ...(snapshot.paramValues || {}) }, sourceMarkers: { ...(snapshot.sourceMarkers || {}) }, generationTimings: { ...(snapshot.generationTimings || {}) }, - deliveredVariants: { ...(snapshot.deliveredVariants || {}) }, variantPlan: snapshot.variantPlan || null, annotationArtifacts: [...(snapshot.annotationArtifacts || [])], diagnostics: [...(snapshot.diagnostics || [])], @@ -183,7 +178,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1); next.pageUrl = event.pageUrl ?? next.pageUrl; next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; @@ -204,40 +198,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { ]; } break; - case 'variant_published': - if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) { - next.diagnostics.push({ - error: 'late_generation_event_ignored', - type: event.type, - phase: next.phase, - revision: event.revision ?? null, - }); - break; - } - if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) { - next.diagnostics.push({ - error: 'stale_generation_epoch_ignored', - epoch: event.generationEpoch ?? null, - expectedEpoch: next.generationEpoch || 1, - }); - break; - } - next.phase = 'variants_progress'; - next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0)); - next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0)); - next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0); - if (event.publicationKind === 'params') next.paramsPublished = true; - next.sourceFile = event.sourceFile ?? next.sourceFile; - next.previewFile = event.previewFile ?? next.previewFile; - next.previewMode = event.previewMode ?? next.previewMode; - if (event.revision) { - next.deliveredVariants[String(event.revision)] = { - digest: event.digest || null, - arrivedVariants: Number(event.arrivedVariants || 0), - publishedAt: event.at || null, - }; - } - break; case 'agent_phase': next.generationPhase = event.phase ?? next.generationPhase; if (event.phase) { diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 8b268d5df..b65c148b7 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -6,7 +6,6 @@ */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { @@ -18,66 +17,6 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns'); -describe('detectText - Astro structural CSS fixtures', () => { - const SHOULD_FLAG = [ - 'Kinpaku Edge', - 'Patina Edge', - 'Accent Edge', - 'Signal Blue Edge', - 'Chromatic Hex Edge', - 'Named Red Edge', - 'Chromatic Rgb Edge', - 'Chromatic Oklch Edge', - // `inset` may follow the offsets/color. Requiring it first missed the same - // stripe written the other legal way. - 'Trailing Inset Edge', - 'Trailing Inset Token Edge', - 'Inset Named Token Edge', - // Only the two offsets are required; blur/spread default to 0. - 'Two Length Edge', - 'Two Length Trailing Inset Edge', - ]; - const SHOULD_PASS = [ - 'Neutral Shadow Token', - 'Current Color Edge', - 'Selected State Edge', - 'Hairline Edge', - 'Thick Fill Edge', - 'Blurred Edge', - 'Narrow Artwork', - // Authored CSS spells neutrals as hex and keywords. isNeutralColor only - // parses the computed function forms and reports everything else as - // chromatic, so routing these through it flagged plain black and gray - // hairlines as the "colored stripe" AI tell. - 'Black Hex Edge', - 'Black Named Edge', - 'Gray Hex Edge', - 'Dimgray Named Edge', - 'Black Rgb Edge', - 'Shorthand Neutral Hex Edge', - // Commented-out CSS is not a live rule. - 'Commented Out Edge', - // Trailing `inset` still respects the neutral-color exemption. - 'Trailing Inset Neutral Edge', - // The short form still respects the neutral and blur exclusions. - 'Two Length Neutral Edge', - 'Two Length Blurred Edge', - ]; - - it('Astro style blocks flag unresolved chromatic inset stripes only', () => { - const filePath = path.join(FIXTURES, 'astro-inset-shadow-stripe.astro'); - const source = fs.readFileSync(filePath, 'utf8'); - const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab'); - const snippets = findings.map(r => r.snippet || '').join(' | '); - for (const heading of SHOULD_FLAG) { - assert.match(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `expected "${heading}" to flag`); - } - for (const heading of SHOULD_PASS) { - assert.doesNotMatch(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `"${heading}" should pass`); - } - }); -}); - describe('detectHtml — static HTML/CSS fixtures', () => { it('should-flag: catches border anti-patterns', async () => { const f = await detectHtml(path.join(FIXTURES, 'should-flag.html')); diff --git a/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro b/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro deleted file mode 100644 index d836e66d4..000000000 --- a/tests/fixtures/antipatterns/astro-inset-shadow-stripe.astro +++ /dev/null @@ -1,88 +0,0 @@ ---- -const title = 'Astro inset shadow stripe regression'; ---- - -
-

{title}

-
-

Should flag

-

Kinpaku Edge

-

Patina Edge

-

Accent Edge

-

Signal Blue Edge

-

Chromatic Hex Edge

-

Named Red Edge

-

Chromatic Rgb Edge

-

Chromatic Oklch Edge

-

Trailing Inset Edge

-

Trailing Inset Token Edge

-

Inset Named Token Edge

-

Two Length Edge

-

Two Length Trailing Inset Edge

-
-
-

Should pass

-

Neutral Shadow Token

-

Current Color Edge

-

Selected State Edge

-

Hairline Edge

-

Thick Fill Edge

-

Blurred Edge

-

Narrow Artwork

-

Black Hex Edge

-

Black Named Edge

-

Gray Hex Edge

-

Dimgray Named Edge

-

Black Rgb Edge

-

Shorthand Neutral Hex Edge

-

Commented Out Edge

-

Trailing Inset Neutral Edge

-

Two Length Neutral Edge

-

Two Length Blurred Edge

-
-
- - diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index c2fa43f87..3fe0000ba 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -53,9 +53,7 @@ import { extractFindingIgnoreValue, resolveProjectPlatform, isNativePlatform, - normalizeIgnoreValueEntries, } from '../skill/scripts/hook-lib.mjs'; -import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs'; import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs'; function mkTmp() { @@ -566,132 +564,6 @@ describe('hook-admin.mjs', () => { assert.match(status, /ignoreValues:\s+overused-font=inter/); }); - // detector.ignoreValues honours a `files` scope, which is the narrowest way to - // silence one noisy rule on one file. hook-admin could not write it, so the - // only reachable option was ignore-file, which silences every rule for that - // file forever. - it('ignore-value scopes a wildcard to files via --file', () => { - const out = runAdmin([ - 'ignore-value', 'design-system-font-size', '*', - '--file', 'src/overlay/widget.js', - '--reason', 'Widget builds its own type scale', - ]); - assert.match(out, /scoped to src\/overlay\/widget\.js/); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; - assert.deepEqual(shared.ignoreValues, [{ - rule: 'design-system-font-size', - value: '*', - files: ['src/overlay/widget.js'], - createdAt: shared.ignoreValues[0].createdAt, - reason: 'Widget builds its own type scale', - }]); - }); - - it('ignore-value accepts --file=, --files= and repeated --file', () => { - runAdmin(['ignore-value', 'side-tab', '*', '--file=a.css']); - runAdmin(['ignore-value', 'side-tab', '*', '--files=b.css']); - runAdmin(['ignore-value', 'low-contrast', '*', '--file', 'c.css', '--file', 'd.css']); - const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; - assert.deepEqual( - shared.ignoreValues.map(({ rule, files }) => ({ rule, files })), - [ - { rule: 'side-tab', files: ['a.css'] }, - { rule: 'side-tab', files: ['b.css'] }, - { rule: 'low-contrast', files: ['c.css', 'd.css'] }, - ], - 'each distinct file scope is its own entry; a rule+value-only key overwrote them', - ); - }); - - it('ignore-value refuses a wildcard with no file scope', () => { - assert.throws( - () => runAdmin(['ignore-value', 'design-system-font-size', '*']), - /Wildcard value ignores must be scoped with --file/, - 'a bare wildcard is ignore-rule\'s job, not a per-file waiver', - ); - assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config'); - }); - - it('ignore-value --file requires a glob', () => { - assert.throws( - () => runAdmin(['ignore-value', 'side-tab', '*', '--file']), - /--file requires a glob/, - ); - }); - - it('ignore-value rejects an unknown flag instead of folding it into the value', () => { - // `--shard` (a typo for --shared) used to store the value "inter --shard", - // which matches nothing, while reporting a successful suppression. - assert.throws( - () => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shard']), - /Unknown ignore-value flag: --shard/, - ); - assert.equal(fs.existsSync(getConfigPath(cwd)), false); - }); - - // Every write runs the entries through normalizeIgnoreValueEntries. Emitting a - // different key order than the one on disk rewrote all untouched entries. - it('an unrelated edit leaves existing ignoreValues byte-identical', () => { - fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); - const seeded = { - detector: { - ignoreRules: [], - ignoreFiles: [], - ignoreValues: [ - { - rule: 'bounce-easing', - value: 'bounce-ball', - createdAt: '2026-06-15T04:15:03.164Z', - reason: 'Intentional', - }, - { - rule: 'design-system-color', - value: '*', - files: ['site/styles/demo.css'], - createdAt: '2026-06-15T23:37:38.170Z', - reason: 'Deliberate off-system demo', - }, - ], - }, - }; - fs.writeFileSync(getConfigPath(cwd), JSON.stringify(seeded, null, 2) + '\n'); - const before = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector.ignoreValues; - - runAdmin(['ignore-file', 'some/other/**']); - - const after = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; - assert.deepEqual(after.ignoreFiles, ['some/other/**'], 'the intended change still lands'); - assert.equal( - JSON.stringify(after.ignoreValues), - JSON.stringify(before), - 'untouched ignoreValues must keep their exact key order, or every config diff churns', - ); - }); - - // hook-lib.mjs (skill, ships into harness dirs) and cli/lib/impeccable-config.mjs - // (CLI + Pages functions) carry independent copies of this normalizer by - // necessity. They write the same file, so a key-order drift between them makes - // the config churn depending on which tool touched it last. - it('both config normalizers emit identical entries', () => { - const input = [ - { rule: 'BOUNCE-EASING', value: 'Bounce-Ball', reason: ' r ', createdAt: '2026-01-01T00:00:00.000Z' }, - { rule: 'design-system-color', value: '*', files: [' a.css ', 'b.css', 'a.css'], createdAt: '2026-02-02T00:00:00.000Z' }, - { rule: 'side-tab', value: '*', file: 'legacy.css' }, - { rule: '', value: 'dropped' }, - ]; - assert.equal( - JSON.stringify(normalizeIgnoreValueEntries(input)), - JSON.stringify(normalizeIgnoreValueEntriesCli(input)), - 'skill/scripts/hook-lib.mjs and cli/lib/impeccable-config.mjs must agree, key order included', - ); - // And pin the canonical order itself, which is what the config on disk uses. - const full = { rule: 'side-tab', value: '*', files: ['a.css'], createdAt: '2026-01-01T00:00:00.000Z', reason: 'r' }; - assert.deepEqual( - Object.keys(normalizeIgnoreValueEntries([full])[0]), - ['rule', 'value', 'files', 'createdAt', 'reason'], - ); - }); - it('a /impeccable hooks edit preserves sibling hook fields (consent, quiet)', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); // A recorded per-developer consent in the local file... diff --git a/tests/live-accept.test.mjs b/tests/live-accept.test.mjs index 57e3c96a0..50e4bc484 100644 --- a/tests/live-accept.test.mjs +++ b/tests/live-accept.test.mjs @@ -74,16 +74,6 @@ describe('live-accept — marker search must ignore Impeccable state', () => { assert.doesNotMatch(source, /impeccable-variants-start/, 'the wrapper must be gone from real source'); }); - it('retires the session’s staged artifacts and leaves other sessions alone', () => { - seed(); - const dir = join(tmp, '.impeccable', 'live', 'artifacts'); - writeFileSync(join(dir, 'ffff0000-r1.astro'), SOURCE); - runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']); - assert.equal(existsSync(join(dir, 'ab12cd34-r1.astro')), false, 'own artifacts must not outlive the session'); - assert.equal(existsSync(join(dir, 'ab12cd34-r3.astro')), false); - assert.equal(existsSync(join(dir, 'ffff0000-r1.astro')), true, 'another session’s artifacts must survive'); - }); - it('discards into real source with an artifact decoy present', () => { seed({ revisions: 1 }); const result = runAccept(tmp, ['--id', 'ab12cd34', '--discard']); diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index a82def257..89271c6f6 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -660,270 +660,7 @@ for (const { name, fixture } of fixtures) { } }); - if (['vite8-react-plain', 'astro-vite7', 'nextjs-app-router', 'vite8-sveltekit', 'nuxt-vite7'].includes(name) && shouldRunScenario('progressive')) { - it('reveals variant 1 safely while the remaining variants and params are pending', liveE2eTestOptions, async (t) => { - if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { - t.skip('manual scenario filter is active'); - return; - } - const traceEvents = []; - const session = await bootFixtureSession({ - name, - fixture, - browser, - agent: createFakeAgent(), - wrapTarget: wrapTargetFromPickedElement, - progressive: true, - progressiveDelayMs: 2500, - trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }), - log: (m) => t.diagnostic(m), - }); - const { page, tmp, consoleErrors, teardown } = session; - let sourceFile = null; - - try { - await waitForHandshake(page); - const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; - const originalCopy = await page.locator(pickSelector).innerText(); - await pickElement(page, pickSelector); - await clickGo(page); - - const partial = await waitForProgressiveReviewState(page, 3); - assert.equal(partial.arrived, 1, 'exactly variant 1 is present during the progressive interval'); - assert.equal(partial.visible, 1, 'variant 1 is the visible review target'); - assert.equal(partial.copy, originalCopy, 'variant 1 preserves the picked copy'); - assert.notEqual(partial.acceptPointerEvents, 'none', 'Accept is available for the first reviewable variant'); - assert.notEqual(partial.discardPointerEvents, 'none', 'Discard can cancel unfinished generation'); - assert.equal(partial.hasParams, false, 'variant 1 has no eager parameter manifest'); - assert.equal(partial.tuneVisible, true, 'Tune stays visible while parameter generation is outstanding'); - assert.equal(partial.tuneDisabled, true, 'pending Tune is non-interactive until controls arrive'); - assert.match(partial.tuneTitle || '', /still being prepared/, 'pending Tune explains its loading state'); - assert.equal(partial.paramsPanelVisible, false, 'the Tune popover stays closed until parameter delivery'); - - sourceFile = await locateSessionFile(tmp); - const isComponentPreview = sourceFile.endsWith('manifest.json'); - if (isComponentPreview) { - const manifest = JSON.parse(readFileSync(sourceFile, 'utf-8')); - sourceFile = join(tmp, manifest.sourceFile); - const extension = manifest.componentExtension || 'svelte'; - assert.equal(existsSync(join(tmp, manifest.componentDir, `v1.${extension}`)), true, 'partial component preview contains variant 1'); - assert.equal(existsSync(join(tmp, manifest.componentDir, 'params.json')), false, 'partial component preview defers parameter manifests'); - } else { - const partialSource = readFileSync(sourceFile, 'utf-8'); - assert.equal(countSourceVariants(partialSource), 1, 'partial source contains one reviewable variant'); - assert.doesNotMatch(partialSource, /data-impeccable-params=/, 'partial source defers parameter manifests'); - } - - // Keyboard Accept must durably fence the worker before its delayed - // second publication, then return the browser to picking without - // waiting for variants the user no longer wants. - const acceptClickedAt = Date.now(); - await clickAccept(page, { expectedVariant: 1 }); - await waitForBarHidden(page); - await page.waitForFunction( - () => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING', - { timeout: 2_000 }, - ); - const automationAcceptToPickingMs = Date.now() - acceptClickedAt; - const browserAcceptToPickingMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs)); - const acceptToPickingMs = Number.isFinite(browserAcceptToPickingMs) && browserAcceptToPickingMs > 0 - ? browserAcceptToPickingMs - : automationAcceptToPickingMs; - t.diagnostic(`Accept dispatch → picker ready: ${acceptToPickingMs}ms (${automationAcceptToPickingMs}ms including Playwright actionability)`); - assert.ok(acceptToPickingMs < 500, `Accept should release the picker within 500ms of dispatch; got ${acceptToPickingMs}ms`); - const finalSource = await waitForSourceClean(sourceFile, 20_000); - assert.match(finalSource, new RegExp(escapeRegExp(originalCopy)), 'early accepted source preserves the original copy'); - assert.doesNotMatch(finalSource, /data-impeccable-variant=/, 'early accepted source is free of preview scaffolding'); - assert.equal(countSourceVariants(finalSource), 0, 'the delayed worker cannot reinsert later variants'); - - const firstGenerateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id; - // Give framework HMR one paint to settle the newly committed tree; - // this stays inside the 1.5s next-pick budget and avoids selecting a - // node instance React is replacing in the same frame. - if (name === 'nextjs-app-router' || name === 'vite8-sveltekit' || name === 'nuxt-vite7') await waitForHandshake(page); - await page.waitForTimeout(250); - await page.mouse.move(1, 1); - const nextPickSelector = name === 'nextjs-app-router' - ? 'main.page' - : name === 'vite8-sveltekit' - ? 'article.feature-card' - : name === 'nuxt-vite7' - ? 'main.page' - : '.hero-hook'; - await pickElement(page, nextPickSelector, { - resetPickMode: name === 'nextjs-app-router' || name === 'nuxt-vite7', - position: name === 'nuxt-vite7' ? { x: 12, y: 12 } : undefined, - }); - const nextGoAt = Date.now(); - await clickGo(page); - let nextGenerateTrace = null; - const pickupDeadline = Date.now() + 1_500; - while (Date.now() < pickupDeadline) { - nextGenerateTrace = traceEvents.find((event) => ( - event.name === 'agent.event.received' - && event.type === 'generate' - && event.id !== firstGenerateId - )); - if (nextGenerateTrace) break; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.ok(nextGenerateTrace, 'the poll supervisor picks up the next generation while the canceled worker unwinds'); - const nextDispatchToPickupMs = nextGenerateTrace.at - nextGenerateTrace.clientSentAt; - assert.ok( - nextDispatchToPickupMs < 1_500, - `next generation pickup should stay below 1.5s from dispatch; got ${nextDispatchToPickupMs}ms`, - ); - t.diagnostic(`Next Go dispatch → generation pickup: ${nextDispatchToPickupMs}ms (${nextGenerateTrace.at - nextGoAt}ms including Playwright actionability)`); - if (process.env.IMPECCABLE_E2E_METRICS_FILE) { - appendFileSync(process.env.IMPECCABLE_E2E_METRICS_FILE, JSON.stringify({ - acceptToPickingMs, - nextGoToPickupMs: nextDispatchToPickupMs, - automationAcceptToPickingMs, - automationNextGoToPickupMs: nextGenerateTrace.at - nextGoAt, - fixture: name, - at: new Date().toISOString(), - }) + '\n'); - } - assert.ok( - traceEvents.some((event) => event.name === 'agent.scaffold.reused'), - 'agent reuses the server preflight scaffold', - ); - assert.equal( - traceEvents.some((event) => event.name === 'agent.scaffold.start'), - false, - 'agent does not repeat deterministic source discovery after preflight', - ); - const generateTrace = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate'); - assert.ok(generateTrace?.id, 'generate trace exposes the durable session id'); - const generationTimings = await waitForGenerationTimings(tmp, generateTrace.id, { requireAllVariants: false }); - assert.ok(generationTimings.generation_ready?.at, 'durable timing records when generation work can start'); - assert.ok(generationTimings.first_reviewable?.at, 'durable timing records the first reviewable variant'); - assert.equal(generationTimings.all_variants_ready, undefined, 'canceled work never records all variants ready'); - - const realErrors = consoleErrors.filter((error) => - !/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error), - ); - if (fixture.runtime.probe?.expectConsoleClean) { - assert.deepEqual(realErrors, [], 'progressive HMR and early-action guards produce no browser errors'); - } else if (realErrors.length > 0) { - t.diagnostic(`Known framework HMR console noise during progressive source rewrites: ${realErrors.length} error(s)`); - for (const error of realErrors) t.diagnostic(error.split('\n')[0]); - } - } finally { - await teardownAndResetBrowser(teardown); - } - }); - } - - if (name === 'vite8-react-plain' && shouldRunScenario('progressive')) { - it('accepts variant 2 while variant 3 is still pending', liveE2eTestOptions, async (t) => { - const traceEvents = []; - const session = await bootFixtureSession({ - name, - fixture, - browser, - agent: createFakeAgent(), - wrapTarget: wrapTargetFromPickedElement, - progressive: true, - progressiveInitialCount: 2, - progressiveDelayMs: 2500, - trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }), - log: (m) => t.diagnostic(m), - }); - const { page, tmp, consoleErrors, teardown } = session; - try { - await waitForHandshake(page); - const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; - const originalCopy = await page.locator(pickSelector).innerText(); - await pickElement(page, pickSelector); - await clickGo(page); - - const partial = await waitForProgressiveReviewState(page, 3, { arrived: 2, visible: 1 }); - assert.equal(partial.arrived, 2, 'variants 1 and 2 arrive before variant 3'); - assert.equal(partial.visible, 1, 'variant 1 remains visible until the user advances'); - assert.notEqual(partial.acceptPointerEvents, 'none', 'arrived variants remain actionable while the tail is pending'); - assert.equal(partial.hasParams, false, 'the partial two-variant revision still defers parameter manifests'); - - await clickNext(page); - const second = await readProgressiveReviewState(page); - assert.equal(second.visible, 2, 'variant 2 is reviewable before variant 3 exists'); - assert.equal(second.copy, originalCopy, 'variant 2 preserves the picked copy'); - - const wrappedSource = await locateSessionFile(tmp); - const acceptStartedAt = Date.now(); - await clickAccept(page, { expectedVariant: 2 }); - await waitForBarHidden(page); - await page.waitForFunction( - () => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING', - { timeout: 2_000 }, - ); - const browserAcceptMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs)); - const acceptToPickingMs = Number.isFinite(browserAcceptMs) && browserAcceptMs > 0 - ? browserAcceptMs - : Date.now() - acceptStartedAt; - assert.ok(acceptToPickingMs < 500, `variant 2 Accept should release the picker within 500ms; got ${acceptToPickingMs}ms`); - - const cleanSource = await waitForSourceClean(wrappedSource, 20_000); - assert.match(cleanSource, new RegExp(escapeRegExp(originalCopy)), 'accepted variant 2 preserves source copy'); - assert.doesNotMatch(cleanSource, /data-impeccable-variant=/, 'accepted variant 2 leaves no preview scaffolding'); - await page.waitForTimeout(2750); - assert.doesNotMatch(readFileSync(wrappedSource, 'utf-8'), /data-impeccable-variant=/, 'the delayed variant 3 write stays fenced'); - - const generateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id; - const timings = await waitForGenerationTimings(tmp, generateId, { requireAllVariants: false }); - assert.equal(timings.all_variants_ready, undefined, 'accepting variant 2 cancels the unfinished third variant'); - const realErrors = consoleErrors.filter((error) => - !/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error), - ); - assert.deepEqual(realErrors, [], 'variant 2 early Accept stays console-clean'); - } finally { - await teardownAndResetBrowser(teardown); - } - }); - - it('promotes pending Tune controls when the params-only revision arrives', liveE2eTestOptions, async (t) => { - const session = await bootFixtureSession({ - name, - fixture, - browser, - agent: createFakeAgent(), - wrapTarget: wrapTargetFromPickedElement, - progressive: true, - progressiveDelayMs: 1500, - log: (message) => t.diagnostic(message), - }); - const { page, teardown } = session; - try { - await waitForHandshake(page); - await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title'); - await clickGo(page); - - const pending = await waitForProgressiveReviewState(page, 3); - assert.equal(pending.tuneVisible, true); - assert.equal(pending.tuneDisabled, true); - - 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"]'); - const wrapper = document.querySelector('[data-impeccable-variants]'); - return tune?.disabled === false - && !!wrapper?.querySelector('[data-impeccable-params]'); - }, { timeout: 10_000 }); - const ready = await readProgressiveReviewState(page); - assert.equal(ready.arrived, 3, 'all variants remain mounted after params publication'); - assert.equal(ready.tuneVisible, true); - assert.equal(ready.tuneDisabled, false, 'Tune becomes actionable without another variant arrival'); - - await clickDiscard(page); - await page.waitForFunction(() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING', { timeout: 2_000 }); - } finally { - await teardownAndResetBrowser(teardown); - } - }); - } if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) { const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || ''; @@ -1074,61 +811,6 @@ function recordGenerateEvents(agent, events) { }; } -async function waitForProgressiveReviewState(page, expected, { arrived: targetArrived = 1, visible: targetVisible = 1 } = {}) { - await installLiveQueryHelpers(page); - await page.waitForFunction(({ variantCount, targetArrived, targetVisible }) => { - const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector)); - const wrapper = query('[data-impeccable-variants]'); - const variants = wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.(); - const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '') - ? Number(debugState?.arrivedVariants || 0) - : variants?.length; - const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() - || window.__IMPECCABLE_LIVE_UI_ROOT__ - || document; - const bar = root.querySelector('#impeccable-live-bar'); - return arrived === targetArrived - && new RegExp(`${targetVisible}\\s*\\/\\s*${variantCount}`).test(bar?.textContent || '') - && /more arriving/.test(bar?.textContent || ''); - }, { variantCount: expected, targetArrived, targetVisible }, { timeout: 15_000 }); - return readProgressiveReviewState(page); -} - -async function readProgressiveReviewState(page) { - await installLiveQueryHelpers(page); - return page.evaluate(() => { - const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector)); - const wrapper = query('[data-impeccable-variants]'); - const variants = [...(wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])') || [])]; - const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.(); - const isSveltePreview = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || ''); - const visibleVariant = variants.find((variant) => getComputedStyle(variant).display !== 'none'); - const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() - || window.__IMPECCABLE_LIVE_UI_ROOT__ - || document; - const buttons = [...root.querySelectorAll('#impeccable-live-bar button')]; - const accept = buttons.find((button) => /Accept/.test(button.textContent || '')); - const discard = buttons.find((button) => (button.textContent || '').includes('✕')); - const paramsPanel = root.querySelector('#impeccable-live-params-panel'); - const tune = root.querySelector('[data-iceq-tune="1"]'); - return { - arrived: isSveltePreview ? Number(debugState?.arrivedVariants || 0) : variants.length, - visible: isSveltePreview ? Number(debugState?.visibleVariant || 0) : Number(visibleVariant?.dataset.impeccableVariant || 0), - copy: isSveltePreview ? (wrapper?.innerText || '') : (visibleVariant?.innerText || ''), - acceptPointerEvents: accept ? getComputedStyle(accept).pointerEvents : null, - discardPointerEvents: discard ? getComputedStyle(discard).pointerEvents : null, - hasParams: variants.some((variant) => variant.hasAttribute('data-impeccable-params')), - tuneVisible: !!tune, - tuneDisabled: tune?.disabled ?? null, - tuneTitle: tune?.title || '', - paramsPanelVisible: !!paramsPanel - && getComputedStyle(paramsPanel).pointerEvents !== 'none' - && getComputedStyle(paramsPanel).clipPath === 'inset(0px)', - }; - }); -} - function countSourceVariants(source) { return (String(source).match(//gi, '') @@ -1683,9 +1563,6 @@ export async function runAgentLoop({ signal, log = () => {}, trace = () => {}, - progressive = false, - progressiveDelayMs = 0, - progressiveInitialCount = 1, atomicDelayMs = 0, wrapTarget = { classes: 'hero-title', tag: 'h1' }, steerSourceFile, @@ -1807,91 +1684,16 @@ export async function runAgentLoop({ // 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 }); - const splitProgressive = progressive - && typeof agent.generateFirstVariant === 'function' - && typeof agent.generateRemainingVariants === 'function' - && event.count > 1; - let output; - let firstOutput; - if (splitProgressive) { - firstOutput = normalizeVariantOutput( - await agent.generateFirstVariant(event, { wrapTarget, wrapInfo }), - wrapInfo, - ); - firstOutput = { - ...firstOutput, - variants: firstOutput.variants.slice(0, 1).map((variant) => ({ ...variant, params: [] })), - }; - trace('agent.generate.first_ready', { id: event.id, count: firstOutput.variants.length }); - trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file }); - if (wrapInfo.previewMode === 'svelte-component') { - await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false }); - } else if (wrapInfo.previewMode === 'vue-component') { - await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false }); - } else { - await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput }); - } - await publishVariantProgress({ - base, - token, - event, - wrapInfo, - arrivedVariants: firstOutput.variants.length, - signal, - }); - trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file }); - output = normalizeVariantOutput( - await agent.generateRemainingVariants(event, { wrapTarget, wrapInfo, firstOutput }), - wrapInfo, - ); - trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 }); - } else { - output = normalizeVariantOutput( - await agent.generateVariants(event, { wrapTarget, wrapInfo }), - wrapInfo, - ); - if (!progressive && atomicDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, atomicDelayMs)); - } - trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 }); - if (!progressive || output.variants.length <= 1) { - trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 }); - } - - if (progressive && output.variants.length > 1) { - const initialCount = Math.max(1, Math.min( - Number(progressiveInitialCount) || 1, - output.variants.length - 1, - )); - firstOutput = { - ...output, - variants: output.variants - .slice(0, initialCount) - .map((variant) => ({ ...variant, params: [] })), - }; - trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file }); - if (wrapInfo.previewMode === 'svelte-component') { - await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false }); - } else if (wrapInfo.previewMode === 'vue-component') { - await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false }); - } else { - await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput }); - } - await publishVariantProgress({ - base, - token, - event, - wrapInfo, - arrivedVariants: firstOutput.variants.length, - signal, - }); - trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file }); - if (progressiveDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, progressiveDelayMs)); - } - trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 }); - } + 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}`); } @@ -1899,27 +1701,13 @@ export async function runAgentLoop({ // 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 publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true }); + await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true }); } else if (wrapInfo.previewMode === 'vue-component') { - await publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true }); - } else if (progressive) { - await publishSourceVariants({ tmp, wrapInfo, event, output }); + 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 (progressive) { - await publishVariantProgress({ - base, - token, - event, - wrapInfo, - arrivedVariants: output.variants.length, - signal, - revision: 2, - publicationKind: 'params', - }); - } 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}`); diff --git a/tests/live-e2e/session.mjs b/tests/live-e2e/session.mjs index 00973a1e5..ad149f8d5 100644 --- a/tests/live-e2e/session.mjs +++ b/tests/live-e2e/session.mjs @@ -236,9 +236,6 @@ export async function bootFixtureSession({ prepareTmp, log = () => {}, trace = () => {}, - progressive = false, - progressiveDelayMs = 0, - progressiveInitialCount = 1, atomicDelayMs = 0, keepTmp = false, }) { @@ -324,18 +321,11 @@ export async function bootFixtureSession({ wrapTarget, signal: agentAbort.signal, trace, - progressive, - progressiveDelayMs, - progressiveInitialCount, atomicDelayMs, steerSourceFile: runtime.steer?.sourceFile, steerTarget: runtime.steer?.target, }; - const loops = [runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]; - if (progressive) { - loops.push(runAgentLoop({ ...loopOptions, log: (m) => log('[supervisor] ' + m) })); - } - agentDone = Promise.all(loops); + agentDone = Promise.all([runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]); } const scheme = runtime.scheme || 'http'; diff --git a/tests/live-generation-publisher.test.mjs b/tests/live-generation-publisher.test.mjs deleted file mode 100644 index fc580bf5b..000000000 --- a/tests/live-generation-publisher.test.mjs +++ /dev/null @@ -1,387 +0,0 @@ -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 { - prepareGenerationArtifact, - publishGenerationArtifact, - sha256, -} from '../skill/scripts/live/generation-publisher.mjs'; - -describe('transactional generation publisher', () => { - let tmp; - let source; - let artifact; - let store; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), 'impeccable-publisher-')); - source = join(tmp, 'page.html'); - artifact = join(tmp, 'variant.html'); - writeFileSync(source, '
Original
'); - store = createLiveSessionStore({ cwd: tmp, sessionId: 'abc12345' }); - store.appendEvent({ - type: 'generate', - id: 'abc12345', - generationEpoch: 1, - action: 'polish', - count: 3, - element: { outerHTML: '
Original
' }, - }); - }); - - afterEach(() => rmSync(tmp, { recursive: true, force: true })); - - it('atomically publishes an artifact that matches the fenced source revision', () => { - const before = readFileSync(source, 'utf-8'); - writeFileSync(artifact, '
Original
Variant
'); - const result = publishGenerationArtifact({ - id: 'abc12345', - epoch: 1, - sourceFile: source, - artifactFile: artifact, - expectedSourceHash: sha256(before), - expectedVariants: 3, - cwd: tmp, - }); - - assert.equal(result.ok, true, JSON.stringify(result)); - assert.equal(result.arrivedVariants, 1); - assert.equal(readFileSync(source, 'utf-8'), readFileSync(artifact, 'utf-8')); - const snapshot = store.getSnapshot('abc12345'); - assert.equal(snapshot.phase, 'variants_progress'); - assert.equal(snapshot.publishedRevision, 1); - assert.equal(snapshot.deliveredVariants['1'].digest, result.digest); - }); - - it('prepares a revision artifact with the current epoch and source fence', () => { - const result = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp }); - assert.equal(result.ok, true); - assert.equal(result.epoch, 1); - assert.equal(result.revision, 1); - assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8'))); - assert.equal(readFileSync(join(tmp, result.artifactFile), 'utf-8'), readFileSync(source, 'utf-8')); - }); - - it('rejects a late publication after early accept without touching source', () => { - const before = readFileSync(source, 'utf-8'); - writeFileSync(artifact, '
Late
'); - store.appendEvent({ type: 'accept', id: 'abc12345', variantId: '1' }); - - const result = publishGenerationArtifact({ - id: 'abc12345', - epoch: 1, - sourceFile: source, - artifactFile: artifact, - expectedSourceHash: sha256(before), - cwd: tmp, - }); - - assert.deepEqual(result, { - ok: false, - error: 'stale_generation_epoch', - canceled: true, - phase: 'accept_requested', - }); - assert.equal(readFileSync(source, 'utf-8'), before); - }); - - it('rejects a stale artifact when source changed after the worker snapshot', () => { - const before = readFileSync(source, 'utf-8'); - writeFileSync(artifact, '
Variant
'); - writeFileSync(source, before.replace('Original', 'Changed')); - - const result = publishGenerationArtifact({ - id: 'abc12345', - epoch: 1, - sourceFile: source, - artifactFile: artifact, - expectedSourceHash: sha256(before), - cwd: tmp, - }); - - assert.equal(result.ok, false); - assert.equal(result.error, 'source_hash_mismatch'); - assert.match(readFileSync(source, 'utf-8'), /Changed/); - }); - - it('keeps an already reviewable source variant immutable across revisions', () => { - const firstSource = '
Original
First
'; - writeFileSync(artifact, firstSource); - const first = publishGenerationArtifact({ - id: 'abc12345', - epoch: 1, - sourceFile: source, - artifactFile: artifact, - expectedSourceHash: sha256(readFileSync(source, 'utf-8')), - arrivedVariants: 1, - expectedVariants: 3, - cwd: tmp, - }); - assert.equal(first.ok, true); - - const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp }); - const changed = firstSource.replace('First', 'Silently changed') - .replace('', '
Second
'); - writeFileSync(join(tmp, prepared.artifactFile), changed); - const result = publishGenerationArtifact({ - id: 'abc12345', - epoch: prepared.epoch, - sourceFile: source, - artifactFile: prepared.artifactFile, - expectedSourceHash: prepared.expectedSourceHash, - arrivedVariants: 2, - expectedVariants: 3, - cwd: tmp, - }); - - assert.equal(result.ok, false); - assert.equal(result.error, 'published_variant_changed'); - assert.equal(result.variant, 1); - assert.equal(readFileSync(source, 'utf-8'), firstSource); - }); - - it('allows the deferred parameter manifest without weakening prior markup immutability', () => { - const firstSource = '
Original

First

'; - writeFileSync(artifact, firstSource); - const first = publishGenerationArtifact({ - id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact, - expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp, - }); - assert.equal(first.ok, true); - - const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp }); - const withParams = firstSource - .replace('
', '
Second
'); - writeFileSync(join(tmp, prepared.artifactFile), withParams); - const result = publishGenerationArtifact({ - id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile, - expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 2, expectedVariants: 3, cwd: tmp, - }); - - assert.equal(result.ok, true, JSON.stringify(result)); - assert.match(readFileSync(source, 'utf-8'), /data-impeccable-params/); - }); - - it('rejects later source revisions that restyle an already reviewable variant', () => { - const firstSource = '
Original

First

'; - writeFileSync(artifact, firstSource); - const first = publishGenerationArtifact({ - id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact, - expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp, - }); - assert.equal(first.ok, true); - - const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp }); - const changed = firstSource.replace('color: red', 'color: blue'); - writeFileSync(join(tmp, prepared.artifactFile), changed); - const result = publishGenerationArtifact({ - id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile, - expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 1, expectedVariants: 3, cwd: tmp, - }); - - assert.equal(result.ok, false); - assert.equal(result.error, 'published_variant_css_changed', JSON.stringify(result)); - assert.equal(readFileSync(source, 'utf-8'), firstSource); - }); -}); - -describe('transactional Svelte component publisher', () => { - let tmp; - let source; - let manifestPath; - let componentDir; - let store; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), 'impeccable-svelte-publisher-')); - source = join(tmp, 'src', 'routes', '+page.svelte'); - componentDir = join(tmp, 'node_modules', '.impeccable-live', 'svelte123'); - manifestPath = join(componentDir, 'manifest.json'); - mkdirSync(join(tmp, 'src', 'routes'), { recursive: true }); - mkdirSync(componentDir, { recursive: true }); - writeFileSync(source, '

{title}

\n'); - writeFileSync(manifestPath, JSON.stringify({ - id: 'svelte123', - previewMode: 'svelte-component', - sourceFile: 'src/routes/+page.svelte', - sourceStartLine: 1, - sourceEndLine: 1, - count: 3, - propContract: [{ prop: 'title', expr: 'title', placeholder: '{title}' }], - originalMarkup: '

{title}

', - componentDir: 'node_modules/.impeccable-live/svelte123', - runtimeModule: '/node_modules/.impeccable-live/__runtime.js', - }, null, 2) + '\n'); - for (let variant = 1; variant <= 3; variant++) { - writeFileSync(join(componentDir, `v${variant}.svelte`), `
Stub ${variant}
\n`); - } - store = createLiveSessionStore({ cwd: tmp, sessionId: 'svelte123' }); - store.appendEvent({ - type: 'generate', - id: 'svelte123', - generationEpoch: 1, - action: 'polish', - count: 3, - element: { outerHTML: '

Original

' }, - }); - }); - - afterEach(() => rmSync(tmp, { recursive: true, force: true })); - - it('prepares an isolated component directory fenced against the real route', () => { - const result = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - - assert.equal(result.ok, true); - assert.equal(result.previewMode, 'svelte-component'); - assert.equal(result.sourceFile, 'node_modules/.impeccable-live/svelte123/manifest.json'); - assert.equal(result.targetSourceFile, 'src/routes/+page.svelte'); - assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8'))); - const artifactManifest = JSON.parse(readFileSync(join(tmp, result.artifactFile), 'utf-8')); - assert.equal(artifactManifest.componentDir, result.componentDir); - assert.equal(readFileSync(join(tmp, result.componentDir, 'v1.svelte'), 'utf-8'), '
Stub 1
\n'); - - writeFileSync(join(tmp, result.componentDir, 'v1.svelte'), '
Prepared only
\n'); - assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '
Stub 1
\n'); - }); - - it('publishes components before committing the arrived manifest and journals preview metadata', () => { - const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - const artifactManifestPath = join(tmp, prepared.artifactFile); - const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8')); - artifactManifest.arrivedVariants = 1; - writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n'); - writeFileSync(join(tmp, prepared.componentDir, 'v1.svelte'), '
First live variant
\n'); - - const result = publishGenerationArtifact({ - id: 'svelte123', - epoch: prepared.epoch, - sourceFile: manifestPath, - artifactFile: artifactManifestPath, - expectedSourceHash: prepared.expectedSourceHash, - arrivedVariants: 1, - expectedVariants: 3, - cwd: tmp, - }); - - assert.equal(result.ok, true); - assert.equal(result.previewMode, 'svelte-component'); - assert.equal(result.sourceFile, 'src/routes/+page.svelte'); - assert.equal(result.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json'); - assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '
First live variant
\n'); - assert.equal(readFileSync(source, 'utf-8'), '

{title}

\n'); - const liveManifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); - assert.equal(liveManifest.arrivedVariants, 1); - assert.equal(liveManifest.componentDir, 'node_modules/.impeccable-live/svelte123'); - const snapshot = store.getSnapshot('svelte123'); - assert.equal(snapshot.arrivedVariants, 1); - assert.equal(snapshot.previewMode, 'svelte-component'); - assert.equal(snapshot.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json'); - }); - - it('keeps published variants immutable across later revisions', () => { - const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - publishSveltePrepared(first, { arrived: 1, edits: { 1: '
First live variant
\n' } }); - const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - const before = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'); - - const result = publishSveltePrepared(second, { - arrived: 2, - edits: { - 1: '
Silently changed first variant
\n', - 2: '
Second live variant
\n', - }, - }); - - assert.equal(result.ok, false); - assert.equal(result.error, 'published_variant_changed'); - assert.equal(result.variant, 1); - assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), before); - assert.equal(JSON.parse(readFileSync(manifestPath, 'utf-8')).arrivedVariants, 1); - }); - - it('publishes later variants and params without rewriting an already reviewable variant', () => { - const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - publishSveltePrepared(first, { arrived: 1, edits: { 1: '
First live variant
\n' } }); - const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - writeFileSync(join(tmp, second.componentDir, 'params.json'), '{"2":[{"id":"density"}]}\n'); - - const result = publishSveltePrepared(second, { - arrived: 3, - edits: { - 2: '
Second live variant
\n', - 3: '
Third live variant
\n', - }, - }); - - assert.equal(result.ok, true); - assert.equal(result.arrivedVariants, 3); - assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '
First live variant
\n'); - assert.equal(readFileSync(join(componentDir, 'v2.svelte'), 'utf-8'), '
Second live variant
\n'); - assert.equal(existsSync(join(componentDir, 'params.json')), true); - assert.deepEqual(JSON.parse(readFileSync(join(componentDir, 'params.json'), 'utf-8')), { - 2: [{ id: 'density' }], - }); - }); - - it('rejects a prepared Svelte publication after Accept without touching live artifacts', () => { - const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp }); - const beforeManifest = readFileSync(manifestPath, 'utf-8'); - const beforeVariant = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'); - store.appendEvent({ type: 'accept', id: 'svelte123', variantId: '1' }); - - const result = publishSveltePrepared(prepared, { - arrived: 1, - edits: { 1: '
Too late
\n' }, - }); - - assert.equal(result.ok, false); - assert.equal(result.error, 'stale_generation_epoch'); - assert.equal(readFileSync(manifestPath, 'utf-8'), beforeManifest); - assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), beforeVariant); - }); - - it('rejects a live component directory masquerading as a staged artifact', () => { - const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); - manifest.arrivedVariants = 1; - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); - - const result = publishGenerationArtifact({ - id: 'svelte123', - epoch: 1, - sourceFile: manifestPath, - artifactFile: manifestPath, - expectedSourceHash: sha256(readFileSync(source, 'utf-8')), - arrivedVariants: 1, - expectedVariants: 3, - cwd: tmp, - }); - - assert.equal(result.ok, false); - assert.equal(result.error, 'artifact_not_staged'); - }); - - function publishSveltePrepared(prepared, { arrived, edits }) { - const artifactManifestPath = join(tmp, prepared.artifactFile); - const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8')); - artifactManifest.arrivedVariants = arrived; - writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n'); - for (const [variant, content] of Object.entries(edits)) { - writeFileSync(join(tmp, prepared.componentDir, `v${variant}.svelte`), content); - } - return publishGenerationArtifact({ - id: 'svelte123', - epoch: prepared.epoch, - sourceFile: manifestPath, - artifactFile: artifactManifestPath, - expectedSourceHash: prepared.expectedSourceHash, - arrivedVariants: arrived, - expectedVariants: 3, - cwd: tmp, - }); - } -}); diff --git a/tests/live-reference.test.mjs b/tests/live-reference.test.mjs index 2506a67a2..c2914b65f 100644 --- a/tests/live-reference.test.mjs +++ b/tests/live-reference.test.mjs @@ -147,7 +147,7 @@ describe('live reference authoring contract', () => { ); assert.doesNotMatch( codexLiveMd, - /<\/?(codex|live-progressive)>/, + /<\/?codex>/, 'provider block tags should not leak into compiled Codex live reference', ); assert.doesNotMatch( @@ -157,55 +157,17 @@ describe('live reference authoring contract', () => { ); }); - it('gives progressive delivery to the harnesses that opt in, and only those', () => { + + it('routes every helper command through the per-provider scripts path', () => { const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8'); - const compileFor = (provider) => compileProviderBlocks(liveMd, PROVIDERS[provider].providerTags); - - // Codex delegates to unblock a foreground poll; Claude Code polls in a - // background task. Both can publish variant 1 before the trio is finished. - for (const provider of ['codex', 'agents', 'claude-code']) { - const compiled = compileFor(provider); - assert.match( - compiled, - /Transactional progressive delivery/, - `${provider} should get the progressive publish recipe`, - ); - assert.match( - compiled, - /Progressive delivery \(Codex, Claude Code\)/, - `${provider} should get the progressive delivery policy`, - ); - } - - // Everyone else keeps the atomic single-edit path until their poll loop is - // known not to stall on the extra publish calls. - for (const provider of ['cursor', 'gemini']) { - const compiled = compileFor(provider); - assert.doesNotMatch( - compiled, - /Transactional progressive delivery|Progressive delivery \(Codex, Claude Code\)/, - `${provider} has not opted into progressive delivery`, - ); - assert.match(compiled, /\*\*Atomic default:\*\*/, `${provider} should keep the atomic path`); - assert.doesNotMatch( - compiled, - /<\/?live-progressive>/, - `capability block tags should not leak into the compiled ${provider} reference`, - ); - } - }); - - it('routes every live-publish command through the per-provider scripts path', () => { - const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8'); - // The progressive recipe used to hardcode `.agents/skills/...`, which is only - // correct for the Codex repo-skills bundle. Every other harness would have - // been told to run the publisher from a directory its install never creates. + // 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}}', ); - assert.match(liveMd, /node \{\{scripts_path\}\}\/live-publish\.mjs --prepare/); }); it('keeps live preview CSS guidance capability-mode driven', () => { diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs index 9bcb2365a..60cf6127e 100644 --- a/tests/live-session-store.test.mjs +++ b/tests/live-session-store.test.mjs @@ -80,21 +80,6 @@ describe('live-session-store', () => { assert.deepEqual(restarted.getSnapshot('planned-session').variantPlan, plan); }); - it('tracks parameter publication separately from variant arrival', () => { - const store = createLiveSessionStore({ cwd: tmp, sessionId: 'parameter-phase' }); - store.appendEvent({ type: 'generate', id: 'parameter-phase', count: 3, generationEpoch: 1 }); - store.appendEvent({ - type: 'variant_published', id: 'parameter-phase', revision: 1, - generationEpoch: 1, arrivedVariants: 3, publicationKind: 'variants', - }); - assert.equal(store.getSnapshot('parameter-phase').paramsPublished, false); - store.appendEvent({ - type: 'variant_published', id: 'parameter-phase', revision: 2, - generationEpoch: 1, arrivedVariants: 3, publicationKind: 'params', - }); - assert.equal(store.getSnapshot('parameter-phase').paramsPublished, true); - }); - it('tombstones generation on early accept and ignores late generation writes', () => { const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' }); store.appendEvent({ diff --git a/tests/live-vue-component.test.mjs b/tests/live-vue-component.test.mjs index b552fadf0..9dc02859e 100644 --- a/tests/live-vue-component.test.mjs +++ b/tests/live-vue-component.test.mjs @@ -5,10 +5,6 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs'; -import { - prepareGenerationArtifact, - publishGenerationArtifact, -} from '../skill/scripts/live/generation-publisher.mjs'; import { inlineVueComponentAccept, nuxtViteFsModulePath, @@ -216,75 +212,4 @@ describe('Nuxt Vue component preview', () => { assert.equal(existsSync(root), false); }); - it('publishes manifest-last, preserves the route, and rejects late work after Accept', () => { - const result = scaffoldVueComponentSession({ - id: 'vue12345', - count: 3, - sourceFile: 'app/pages/index.vue', - sourceStartLine: 3, - sourceEndLine: 3, - originalLines: ['

Hello {{ user.name }}

'], - cwd: tmp, - }); - const store = createLiveSessionStore({ cwd: tmp, sessionId: 'vue12345' }); - store.appendEvent({ - type: 'generate', - id: 'vue12345', - generationEpoch: 1, - count: 3, - action: 'polish', - element: { outerHTML: '

Hello Paul

' }, - }); - const routeBefore = readFileSync(source, 'utf-8'); - const prepared = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp }); - assert.equal(prepared.ok, true); - assert.equal(prepared.previewMode, 'vue-component'); - const artifactManifest = JSON.parse(readFileSync(join(tmp, prepared.artifactFile), 'utf-8')); - artifactManifest.arrivedVariants = 1; - writeFileSync(join(tmp, prepared.artifactFile), JSON.stringify(artifactManifest, null, 2) + '\n'); - writeFileSync(join(tmp, prepared.componentDir, 'v1.vue'), '\n'); - - const published = publishGenerationArtifact({ - id: 'vue12345', - epoch: prepared.epoch, - sourceFile: result.manifestFile, - artifactFile: prepared.artifactFile, - expectedSourceHash: prepared.expectedSourceHash, - arrivedVariants: 1, - expectedVariants: 3, - cwd: tmp, - }); - assert.equal(published.ok, true); - assert.equal(published.previewMode, 'vue-component'); - assert.equal(readFileSync(source, 'utf-8'), routeBefore); - assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1); - - const late = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp }); - const lateManifest = JSON.parse(readFileSync(join(tmp, late.artifactFile), 'utf-8')); - lateManifest.arrivedVariants = 2; - writeFileSync(join(tmp, late.artifactFile), JSON.stringify(lateManifest, null, 2) + '\n'); - writeFileSync(join(tmp, late.componentDir, 'v2.vue'), '\n'); - store.appendEvent({ type: 'accept', id: 'vue12345', variantId: '1' }); - const rejected = publishGenerationArtifact({ - id: 'vue12345', - epoch: late.epoch, - sourceFile: result.manifestFile, - artifactFile: late.artifactFile, - expectedSourceHash: late.expectedSourceHash, - arrivedVariants: 2, - expectedVariants: 3, - cwd: tmp, - }); - assert.equal(rejected.ok, false); - assert.equal(rejected.error, 'stale_generation_epoch'); - assert.equal(readFileSync(source, 'utf-8'), routeBefore); - assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1, - 'the manifest must still advertise only the variant published before Accept'); - // A rejected publish must not have touched the session dir at all: v2 still - // holds its untouched scaffold stub rather than the late variant's markup. - const v2AfterReject = readFileSync(join(tmp, result.componentDir, 'v2.vue'), 'utf-8'); - assert.doesNotMatch(v2AfterReject, /Second/, - 'a rejected late publish must not write variant files into the session dir'); - assert.match(v2AfterReject, /Variant 2: add scoped CSS here/, 'v2 must still be the scaffold stub'); - }); });