/** * Tests for live-accept.mjs — the deterministic accept/discard helper. * Run with: node --test tests/live-accept.test.mjs */ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; 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, 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'); const WRAP = resolve(__dirname, '..', 'skill/scripts/live-wrap.mjs'); function runAccept(cwd, args) { try { const out = execFileSync('node', [ACCEPT, ...args], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); return JSON.parse(out.trim()); } catch (err) { const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || ''; return JSON.parse(body || '{}'); } } // The failure that broke the first real Claude Code Live run. Progressive // publication stages `.impeccable/live/artifacts/-r.`, 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 = [ '
', '', '
ORIGINAL
', '
VARIANT ONE
', '', '
', '', ].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'), [ '', '
ORIGINAL CONTENT
', '
VARIANT ONE
', '', '', ].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 = [ '', '
ORIGINAL
', '
VARIANT ONE
', '', '', ].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-')); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); // Historical bug: extractVariant flipped into "inStyle" mode on 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 block', () => { const html = `

original

variant one

variant two

variant three

`; writeFileSync(join(tmp, 'page.html'), html); const result = runAccept(tmp, ['--id', 'ONELINE', '--variant', '3']); assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'page.html'), 'utf-8'); assert.ok(after.includes('data-impeccable-variant="3"'), 'accepted wrapper for variant 3 present'); assert.ok(after.includes('variant three'), 'variant 3 content kept'); assert.ok(!after.includes('variant two'), 'other variant content dropped'); }); // Baseline: the standard multi-line case must keep working. it('finds the accepted variant after a multi-line block (regression baseline)', () => { const html = `

original

variant one

variant two

`; writeFileSync(join(tmp, 'page.html'), html); const result = runAccept(tmp, ['--id', 'MULTI', '--variant', '1']); assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'page.html'), 'utf-8'); assert.ok(after.includes('data-impeccable-variant="1"'), 'accepted wrapper for variant 1 present'); assert.ok(after.includes('variant one'), 'variant 1 content kept'); }); // Regression: the agent writes JSX and live-accept's // extractCss used to capture the `{` … `` ` ``}` template-literal punctuation // as CSS content. handleAccept then re-wrapped with another `{` … // `` ` ``}`, producing nested template literals (`\n` + `

variant one

\n` + `

variant two

\n` + ` \n` + ` {/* impeccable-variants-end TPL */}\n` + ` \n` + ` \n` + ` );\n` + `}\n`; writeFileSync(join(tmp, 'App.tsx'), tsx); const result = runAccept(tmp, ['--id', 'TPL', '--variant', '1']); assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); // Exactly one `{` opener after the carbonized \n` + `

variant one

\n` + `

variant two

\n` + ` \n` + ` {/* impeccable-variants-end INLINE */}\n` + ` \n` + ` \n` + ` );\n` + `}\n`; writeFileSync(join(tmp, 'App.tsx'), tsx); const result = runAccept(tmp, ['--id', 'INLINE', '--variant', '1']); assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); const inner = after.match(/
nested text
variant two
{/* impeccable-variants-end ROOTIND */} `; writeFileSync(join(tmp, 'Root.tsx'), tsx); const result = runAccept(tmp, ['--id', 'ROOTIND', '--variant', '1']); assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'Root.tsx'), 'utf-8'); assert.match( after, /
\n
\n nested text<\/span>\n <\/section>\n <\/div>/, 'column-0 JSX accept preserves relative indentation inside the carbonize variant wrapper', ); }); // Cursor Bugbot regression (PR #118 review): the JSX wrapper places // marker comments INSIDE the outer
, so block.start sits 2 spaces // deeper than the original element. Using block.start as the deindent // base on JSX accept/discard pushes every restored line 2 spaces too far // right. The fix anchors the indent on `replaceRange.start` (the outer // wrapper line), which is at the original element's indent level for // both HTML and JSX. it('discard restores JSX content at the original indent (no 2-space drift from marker-inside layout)', () => { // Run the real wrap CLI so we exercise the JSX-marker-inside-wrapper // layout end to end, not a hand-rolled approximation. const tsx = `export default function App() { return (
); }`; writeFileSync(join(tmp, 'App.tsx'), tsx); execFileSync('node', [WRAP, '--id', 'INDENTDISC', '--count', '3', '--classes', 'card', '--tag', 'aside', '--file', join(tmp, 'App.tsx')], { cwd: tmp, encoding: 'utf-8', }); runAccept(tmp, ['--id', 'INDENTDISC', '--discard']); const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); // The aside opener should land at exactly 6 spaces — same as the // original — and the

child at 8 (preserved relative depth). // The earlier 6/6/6 collapse was caused by `originalLines.map(l => // indent + ' ' + l.trimStart())` in live-wrap stripping ALL // leading whitespace before reindenting; the fix strips only the // COMMON minimum so the relative structure is preserved. assert.match(after, /^

// orphaned in the file after accept/discard — and worse, an // unrelated
right after the wrapper got // its own
mis-counted as the wrapper close. const tsx = `export default function App() { return (
After
); }`; writeFileSync(join(tmp, 'App.tsx'), tsx); execFileSync('node', [WRAP, '--id', 'MULTILINESC', '--count', '3', '--classes', 'card', '--tag', 'aside', '--file', join(tmp, 'App.tsx')], { cwd: tmp, encoding: 'utf-8', }); const result = runAccept(tmp, ['--id', 'MULTILINESC', '--discard']); assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); // The wrapper scaffold must be fully gone — no orphan
from // the outer wrapper, and no impeccable markers/data attributes. assert.ok(!after.includes('data-impeccable-variants'), `outer wrapper div must be fully removed; got:\n${after}`); assert.ok(!after.includes('data-impeccable-variant'), `original-div wrapper must be fully removed; got:\n${after}`); assert.ok(!after.includes('impeccable-variants-start'), `start marker must be removed; got:\n${after}`); // The unrelated
After
sibling // must survive intact — Bugbot's worst-case scenario was the depth // walk eating its as the wrapper close. assert.ok(after.includes('
After
'), `unrelated next-card sibling must be preserved; got:\n${after}`); // The multi-line self-closing div inside the original element must // survive too. assert.match(after, //m, `multi-line self-closing
inside original must survive; got:\n${after}`); }); it('expandReplaceRange finds JSX wrapper openers with long multi-line attributes', () => { const extraAttrs = Array.from({ length: 18 }, (_, i) => ` data-extra-${i}="x"`).join('\n'); const tsx = `export default function App() { return (
{/* impeccable-variants-start LONGOPEN */} {/* Original */}
{/* Variants: insert below this line */}
{/* impeccable-variants-end LONGOPEN */}
After
); }`; writeFileSync(join(tmp, 'App.tsx'), tsx); const result = runAccept(tmp, ['--id', 'LONGOPEN', '--discard']); assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`); const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); assert.doesNotMatch(after, /data-impeccable-variants/); assert.doesNotMatch(after, /impeccable-variants-start/); assert.match(after, /