mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
The first real Claude Code Live run failed, and the subagent was not the cause. Root cause: progressive publication stages each revision as `.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, nothing ever deleted them, and findSessionFile's walker skipped only node_modules/.git/dist/build. It searches src, app, pages, ... 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 accept found the artifact instead of the real file. Two outcomes, both reproduced: where isGeneratedFile returns true it declines with mode: 'fallback' (what the run hit, after which the agent hand-carbonized several hundred lines across three stylesheets, including unrequested drive-by edits); where it returns false, accept writes the variant into the throwaway artifact and reports handled: true while real source never changes. The E2E suite could not have caught this. Every fixture puts source under `src/`, which is searched before the `.` walk can reach `.impeccable`. Five framework fixtures and three progressive scenarios pass because of fixture layout, not because the path works. I read that as evidence and shouldn't have. - Never search `.impeccable`: it is Impeccable's own state, never project source. - Retire a session's staged artifacts on accept/discard, so they cannot outlive the session and become a decoy for anything else that walks the tree. - Regression tests use a site/pages layout with artifacts present. All three fail against the previous code. Generator subagent removed, on both harnesses: The parent must hand-compress the design system into the handoff, and compression is lossy. Measured on the real run: a 6,826-char handoff carrying exactly one token reference, after the parent had itself read kinpaku-tokens.css. The subagent then spent 3 of its first 9 turns hunting DESIGN.md, gave up, and emitted 0 var(--token) uses and 22 raw oklch literals — violating its own spec's "Never invent raw colors when tokens exist" — including a 1:1 gold-on-gold contrast bug. Isolation is not a benefit here; knowing the design system is the job. Generation stays in the main thread, which already holds the tokens and writes them from the first byte, so carbonize is a move rather than a translation. Copy edits keep their subagent: applying a known set of ops to a named file is self-contained, so an isolated context costs nothing. That is the line. Progressive delivery stays for Codex and Claude Code, main-thread driven. Claude Code keeps the full benefit because its poll is a background task. Codex's poll blocks the foreground, so with no subagent the user sees variant 1 early via HMR but cannot accept it until the trio finishes; that is the cost of the simplification and it is worth naming. Prepared with AI assistance under maintainer direction. Co-Authored-By: Claude <noreply@anthropic.com>
768 lines
36 KiB
JavaScript
768 lines
36 KiB
JavaScript
/**
|
||
* 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 { scaffoldSourceArtifactSession } from '../skill/scripts/live/source-artifact.mjs';
|
||
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/<id>-r<n>.<source-ext>`, which
|
||
// carries the session marker. findSessionFile walks `src`, `app`, `pages`, ... and
|
||
// then `.`; a project whose source is not under one of those (this repo's own site
|
||
// lives in `site/pages/`) falls through to the `.` walk, where dot-directories sort
|
||
// before letters — so the artifact was found before the real file.
|
||
describe('live-accept — marker search must ignore Impeccable state', () => {
|
||
let tmp;
|
||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-decoy-')); });
|
||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||
|
||
const SOURCE = [
|
||
'<main>',
|
||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||
'<div data-impeccable-variant="original">ORIGINAL</div>',
|
||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||
'</main>',
|
||
'',
|
||
].join('\n');
|
||
|
||
function seed({ revisions = 3 } = {}) {
|
||
mkdirSync(join(tmp, 'site', 'pages'), { recursive: true });
|
||
mkdirSync(join(tmp, '.impeccable', 'live', 'artifacts'), { recursive: true });
|
||
writeFileSync(join(tmp, 'site', 'pages', 'index.astro'), SOURCE);
|
||
for (let r = 1; r <= revisions; r += 1) {
|
||
writeFileSync(join(tmp, '.impeccable', 'live', 'artifacts', `ab12cd34-r${r}.astro`), SOURCE);
|
||
}
|
||
}
|
||
|
||
it('accepts into real source when a staged artifact carries the same marker', () => {
|
||
seed();
|
||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
|
||
assert.equal(result.handled, true, JSON.stringify(result));
|
||
assert.equal(
|
||
result.file,
|
||
'site/pages/index.astro',
|
||
'accept must resolve the project file, not the .impeccable artifact decoy',
|
||
);
|
||
const source = readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8');
|
||
assert.match(source, /VARIANT ONE/);
|
||
assert.doesNotMatch(source, /impeccable-variants-start/, 'the wrapper must be gone from real source');
|
||
});
|
||
|
||
it('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']);
|
||
assert.equal(result.handled, true, JSON.stringify(result));
|
||
assert.equal(result.file, 'site/pages/index.astro');
|
||
assert.match(readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8'), /ORIGINAL/);
|
||
});
|
||
});
|
||
|
||
describe('live-accept — session id validation', () => {
|
||
let tmp;
|
||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-id-')); });
|
||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||
|
||
// --id becomes a path segment for the accept receipt. Traversal here wrote
|
||
// JSON to arbitrary absolute paths (e.g. `--id ../../../../etc/evil`).
|
||
for (const id of ['../../../../etc/evil', 'a/b', '..', 'a\\b', '']) {
|
||
it(`refuses --id ${JSON.stringify(id)} without writing a receipt`, () => {
|
||
const res = spawnSync('node', [ACCEPT, '--id', id, '--discard'], {
|
||
cwd: tmp,
|
||
encoding: 'utf-8',
|
||
});
|
||
assert.equal(res.status, 1, 'must exit non-zero');
|
||
assert.match(res.stderr, /Invalid --id|Missing --id/);
|
||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false);
|
||
});
|
||
}
|
||
|
||
it('still accepts a well-formed id', () => {
|
||
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--discard'], {
|
||
cwd: tmp,
|
||
encoding: 'utf-8',
|
||
});
|
||
assert.doesNotMatch(res.stderr || '', /Invalid --id/);
|
||
});
|
||
|
||
// --variant is interpolated into a RegExp and into the markup written back to
|
||
// source. `.*` matched the `original` block first, so the CLI reported a
|
||
// successful accept while actually restoring the original.
|
||
for (const variant of ['.*', '[12]', 'original', '1e2', '']) {
|
||
it(`refuses --variant ${JSON.stringify(variant)} rather than matching by regex`, () => {
|
||
writeFileSync(join(tmp, 'page.html'), [
|
||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||
'<div data-impeccable-variant="original">ORIGINAL CONTENT</div>',
|
||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||
'',
|
||
].join('\n'));
|
||
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--variant', variant], {
|
||
cwd: tmp,
|
||
encoding: 'utf-8',
|
||
});
|
||
assert.equal(res.status, 1);
|
||
assert.match(res.stderr, /Invalid --variant|Need --discard/);
|
||
assert.match(
|
||
readFileSync(join(tmp, 'page.html'), 'utf-8'),
|
||
/impeccable-variants-start/,
|
||
'a rejected variant must leave the wrapper untouched',
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('live-accept — isolated source artifacts', () => {
|
||
let tmp;
|
||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-isolated-')); });
|
||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||
|
||
function scaffold(id) {
|
||
const original = '<main>\n <section class="hero"><h1>Original</h1></section>\n</main>\n';
|
||
writeFileSync(join(tmp, 'page.html'), original);
|
||
const session = scaffoldSourceArtifactSession({
|
||
id,
|
||
count: 2,
|
||
sourceFile: 'page.html',
|
||
sourceStartLine: 2,
|
||
sourceEndLine: 2,
|
||
originalSource: '<section class="hero"><h1>Original</h1></section>',
|
||
previewContent: `<main>
|
||
<!-- impeccable-variants-start ${id} -->
|
||
<div data-impeccable-variants="${id}" data-impeccable-variant-count="2" style="display: contents">
|
||
<div data-impeccable-variant="original"><section class="hero"><h1>Original</h1></section></div>
|
||
<div data-impeccable-variant="1"><section class="hero"><h1>Accepted one</h1></section></div>
|
||
<div data-impeccable-variant="2"><section class="hero"><h1>Accepted two</h1></section></div>
|
||
</div>
|
||
<!-- impeccable-variants-end ${id} -->
|
||
</main>
|
||
`,
|
||
cwd: tmp,
|
||
});
|
||
return { original, session };
|
||
}
|
||
|
||
it('accepts one preview into true source exactly once', () => {
|
||
const { session } = scaffold('isolatedaccept');
|
||
const result = runAccept(tmp, ['--id', 'isolatedaccept', '--variant', '2']);
|
||
assert.equal(result.handled, true, JSON.stringify(result));
|
||
const source = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||
assert.match(source, /Accepted two/);
|
||
assert.doesNotMatch(source, /Accepted one|data-impeccable-variant/);
|
||
assert.equal(existsSync(join(tmp, session.sessionDir)), false);
|
||
});
|
||
|
||
it('discards the preview instantly without touching true source', () => {
|
||
const { original, session } = scaffold('isolateddiscard');
|
||
const result = runAccept(tmp, ['--id', 'isolateddiscard', '--discard']);
|
||
assert.equal(result.handled, true, JSON.stringify(result));
|
||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), original);
|
||
assert.equal(existsSync(join(tmp, session.sessionDir)), false);
|
||
});
|
||
|
||
// reference/live.md routes on `mode`: without it the agent is told "manual
|
||
// cleanup: read file, find markers, edit". There are no markers in source for
|
||
// an isolated preview, so every failure here must self-describe as mode:error.
|
||
it('marks a failed artifact accept as mode:error, not a manual handoff', () => {
|
||
scaffold('isolatedmissing');
|
||
const result = runAccept(tmp, ['--id', 'isolatedmissing', '--variant', '9']);
|
||
assert.equal(result.handled, false, JSON.stringify(result));
|
||
assert.equal(result.mode, 'error', 'the agent must not be told to hand-edit a source file with no markers');
|
||
assert.equal(result.previewMode, 'source-artifact');
|
||
});
|
||
|
||
it('marks an artifact accept blocked by the source lock as mode:error', () => {
|
||
const { original } = scaffold('isolatedlockacc');
|
||
const realTmp = realpathSync(tmp);
|
||
const lockPath = sourceLockPath(join(realTmp, 'page.html'), realTmp);
|
||
mkdirSync(dirname(lockPath), { recursive: true });
|
||
writeFileSync(lockPath, JSON.stringify({
|
||
owner: 'generation:isolatedlockacc:1', token: 'other', pid: process.pid, at: Date.now(),
|
||
}) + '\n');
|
||
|
||
const result = runAccept(tmp, ['--id', 'isolatedlockacc', '--variant', '1']);
|
||
assert.equal(result.handled, false, JSON.stringify(result));
|
||
assert.equal(result.mode, 'error');
|
||
assert.equal(result.error, 'source_locked');
|
||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), original, 'source must be untouched');
|
||
});
|
||
|
||
// Every other discard path (Vue, Svelte, plain wrapper) takes the source lock.
|
||
// This one deleted the preview bare, so it could pull the artifact out from
|
||
// under an in-flight publisher instead of serializing behind it.
|
||
it('serializes the discard behind a publisher holding the source lock', () => {
|
||
const { original, session } = scaffold('isolatedlocked');
|
||
// realpath: on macOS mkdtemp hands back /var/... while the child process's
|
||
// cwd resolves to /private/var/..., and the lock digest hashes the absolute
|
||
// path. Hash the same string the child will.
|
||
const realTmp = realpathSync(tmp);
|
||
const lockPath = sourceLockPath(join(realTmp, 'page.html'), realTmp);
|
||
mkdirSync(dirname(lockPath), { recursive: true });
|
||
// A live holder: process.pid is alive, so the lock is not stale.
|
||
writeFileSync(lockPath, JSON.stringify({
|
||
owner: 'generation:isolatedlocked:1', token: 'other', pid: process.pid, at: Date.now(),
|
||
}) + '\n');
|
||
|
||
const result = runAccept(tmp, ['--id', 'isolatedlocked', '--discard']);
|
||
assert.equal(result.handled, false, JSON.stringify(result));
|
||
assert.equal(result.error, 'source_locked');
|
||
assert.equal(
|
||
existsSync(join(tmp, session.sessionDir)),
|
||
true,
|
||
'the preview must survive: deleting it under the publisher is the race',
|
||
);
|
||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), original);
|
||
});
|
||
});
|
||
|
||
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 <style and
|
||
// scanned for </style> line-by-line. JSX self-closing <style ... /> has no
|
||
// separate closer, so it got stuck forever and missed data-impeccable-variant
|
||
// divs that came after.
|
||
it('finds the accepted variant after a JSX self-closing <style /> block', () => {
|
||
const html = `<body>
|
||
<!-- impeccable-variants-start SELFC -->
|
||
<div data-impeccable-variants="SELFC" data-impeccable-variant-count="3" style="display: contents">
|
||
<div data-impeccable-variant="original">
|
||
<p class="hook">original text</p>
|
||
</div>
|
||
<style data-impeccable-css="SELFC" dangerouslySetInnerHTML={{ __html: '@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }' }} />
|
||
<div data-impeccable-variant="1">
|
||
<p class="hook">variant one</p>
|
||
</div>
|
||
<div data-impeccable-variant="2" style="display: none">
|
||
<p class="hook">variant two</p>
|
||
</div>
|
||
<div data-impeccable-variant="3" style="display: none">
|
||
<p class="hook">variant three</p>
|
||
</div>
|
||
</div>
|
||
<!-- impeccable-variants-end SELFC -->
|
||
</body>`;
|
||
writeFileSync(join(tmp, 'page.html'), html);
|
||
|
||
const result = runAccept(tmp, ['--id', 'SELFC', '--variant', '2']);
|
||
assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`);
|
||
|
||
const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||
// Self-closing style has no extractable CSS body, so there's nothing to carbonize —
|
||
// no carbonize block, no data-impeccable-variant wrapper (it would serve no purpose).
|
||
assert.ok(!after.includes('impeccable-carbonize-start'), 'no carbonize block (self-closing style has no body)');
|
||
assert.ok(!after.includes('impeccable-variants-start'), 'variant markers removed');
|
||
assert.ok(after.includes('variant two'), 'variant 2 content kept');
|
||
assert.ok(!after.includes('variant three'), 'other variant content dropped');
|
||
assert.ok(!after.includes('variant one'), 'other variant content dropped');
|
||
assert.ok(!after.includes('original text'), 'original content dropped');
|
||
});
|
||
|
||
it('replays a durable receipt when Accept is retried after source was already written', () => {
|
||
const html = `<body>
|
||
<!-- impeccable-variants-start RECEIPT1 -->
|
||
<div data-impeccable-variants="RECEIPT1" data-impeccable-variant-count="2" style="display: contents">
|
||
<div data-impeccable-variant="original"><p>original</p></div>
|
||
<style data-impeccable-css="RECEIPT1" />
|
||
<div data-impeccable-variant="1"><p>accepted once</p></div>
|
||
<div data-impeccable-variant="2" style="display: none"><p>other</p></div>
|
||
</div>
|
||
<!-- impeccable-variants-end RECEIPT1 -->
|
||
</body>`;
|
||
writeFileSync(join(tmp, 'page.html'), html);
|
||
|
||
const first = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
|
||
const afterFirst = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||
const replay = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
|
||
|
||
assert.equal(first.handled, true);
|
||
assert.equal(replay.handled, true);
|
||
assert.equal(replay.alreadyApplied, true);
|
||
assert.equal(replay.file, 'page.html');
|
||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), afterFirst);
|
||
const conflict = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '2']);
|
||
assert.equal(conflict.handled, false);
|
||
assert.equal(conflict.error, 'accept_receipt_conflict');
|
||
});
|
||
|
||
// Variant: same-line <style>…</style> block should also be treated as a
|
||
// single skipped unit; the line has both open and close tags.
|
||
it('finds the accepted variant after a single-line <style>…</style> block', () => {
|
||
const html = `<body>
|
||
<!-- impeccable-variants-start ONELINE -->
|
||
<div data-impeccable-variants="ONELINE" data-impeccable-variant-count="3" style="display: contents">
|
||
<div data-impeccable-variant="original"><p class="hook">original</p></div>
|
||
<style data-impeccable-css="ONELINE">@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }</style>
|
||
<div data-impeccable-variant="1"><p class="hook">variant one</p></div>
|
||
<div data-impeccable-variant="2" style="display: none"><p class="hook">variant two</p></div>
|
||
<div data-impeccable-variant="3" style="display: none"><p class="hook">variant three</p></div>
|
||
</div>
|
||
<!-- impeccable-variants-end ONELINE -->
|
||
</body>`;
|
||
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 <style>...</style> case must keep working.
|
||
it('finds the accepted variant after a multi-line <style>…</style> block (regression baseline)', () => {
|
||
const html = `<body>
|
||
<!-- impeccable-variants-start MULTI -->
|
||
<div data-impeccable-variants="MULTI" data-impeccable-variant-count="3" style="display: contents">
|
||
<div data-impeccable-variant="original"><p class="hook">original</p></div>
|
||
<style data-impeccable-css="MULTI">
|
||
@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }
|
||
@scope ([data-impeccable-variant="2"]) { .hook { color: green; } }
|
||
</style>
|
||
<div data-impeccable-variant="1"><p class="hook">variant one</p></div>
|
||
<div data-impeccable-variant="2" style="display: none"><p class="hook">variant two</p></div>
|
||
</div>
|
||
<!-- impeccable-variants-end MULTI -->
|
||
</body>`;
|
||
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 <style>{`…`}</style> 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 (`<style>{`{`@scope…`}`}`)
|
||
// that oxc rejects with "Expected `}` but found `@`". extractCss must
|
||
// strip the JSX wrap regardless of where the agent placed it.
|
||
it('carbonize does not double-wrap when the variants block uses JSX template literals on their own lines', () => {
|
||
const tsx = `export default function App() {\n` +
|
||
` return (\n` +
|
||
` <main>\n` +
|
||
` <>\n` +
|
||
` {/* impeccable-variants-start TPL */}\n` +
|
||
` <div data-impeccable-variants="TPL" data-impeccable-variant-count="2" style={{ display: 'contents' }}>\n` +
|
||
` <div data-impeccable-variant="original"><p className="hook">orig</p></div>\n` +
|
||
` <style data-impeccable-css="TPL">\n` +
|
||
" {`\n" +
|
||
` @scope ([data-impeccable-variant="1"]) { .hook { color: red; } }\n` +
|
||
` @scope ([data-impeccable-variant="2"]) { .hook { color: green; } }\n` +
|
||
" `}\n" +
|
||
` </style>\n` +
|
||
` <div data-impeccable-variant="1"><p className="hook">variant one</p></div>\n` +
|
||
` <div data-impeccable-variant="2" style={{ display: 'none' }}><p className="hook">variant two</p></div>\n` +
|
||
` </div>\n` +
|
||
` {/* impeccable-variants-end TPL */}\n` +
|
||
` </>\n` +
|
||
` </main>\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 <style ...> tag — not two.
|
||
const carbonStyleMatch = after.match(/<style data-impeccable-css="TPL">([\s\S]*?)<\/style>/);
|
||
assert.ok(carbonStyleMatch, 'carbonize <style> block present');
|
||
const inner = carbonStyleMatch[1];
|
||
// Inner must open with one `{` ... and end with one ` `` ... — no nesting.
|
||
const openCount = (inner.match(/\{`/g) || []).length;
|
||
const closeCount = (inner.match(/`\}/g) || []).length;
|
||
assert.equal(openCount, 1, `expected exactly one {\` opener, got ${openCount}`);
|
||
assert.equal(closeCount, 1, `expected exactly one \`} closer, got ${closeCount}`);
|
||
// CSS content survived intact.
|
||
assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept');
|
||
assert.match(
|
||
after,
|
||
/\n <div data-impeccable-variant="1" style=\{\{ display: 'contents' \}\}>\n <p className="hook">variant one<\/p>\n <\/div>/,
|
||
'accepted JSX content is indented inside the temporary carbonize variant wrapper',
|
||
);
|
||
});
|
||
|
||
// Same shape, but the agent put `{`` and ``\`}` attached to first/last CSS
|
||
// lines instead of on dedicated lines. Tests the inline-strip branch.
|
||
it('carbonize does not double-wrap when JSX template-literal punctuation hugs the CSS lines', () => {
|
||
const tsx = `export default function App() {\n` +
|
||
` return (\n` +
|
||
` <main>\n` +
|
||
` <>\n` +
|
||
` {/* impeccable-variants-start INLINE */}\n` +
|
||
` <div data-impeccable-variants="INLINE" data-impeccable-variant-count="2" style={{ display: 'contents' }}>\n` +
|
||
` <div data-impeccable-variant="original"><p className="hook">orig</p></div>\n` +
|
||
` <style data-impeccable-css="INLINE">\n` +
|
||
" {`@scope ([data-impeccable-variant=\"1\"]) { .hook { color: red; } }\n" +
|
||
" @scope ([data-impeccable-variant=\"2\"]) { .hook { color: green; } }`}\n" +
|
||
` </style>\n` +
|
||
` <div data-impeccable-variant="1"><p className="hook">variant one</p></div>\n` +
|
||
` <div data-impeccable-variant="2" style={{ display: 'none' }}><p className="hook">variant two</p></div>\n` +
|
||
` </div>\n` +
|
||
` {/* impeccable-variants-end INLINE */}\n` +
|
||
` </>\n` +
|
||
` </main>\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(/<style data-impeccable-css="INLINE">([\s\S]*?)<\/style>/)[1];
|
||
const openCount = (inner.match(/\{`/g) || []).length;
|
||
const closeCount = (inner.match(/`\}/g) || []).length;
|
||
assert.equal(openCount, 1, `expected one {\` opener, got ${openCount}`);
|
||
assert.equal(closeCount, 1, `expected one \`} closer, got ${closeCount}`);
|
||
assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept');
|
||
});
|
||
|
||
it('carbonize preserves nested JSX indentation when the wrapper starts at column 0', () => {
|
||
const tsx = `<div data-impeccable-variants="ROOTIND" data-impeccable-variant-count="2" style={{ display: 'contents' }}>
|
||
{/* impeccable-variants-start ROOTIND */}
|
||
<div data-impeccable-variant="original">
|
||
<section className="hook">
|
||
<span>original</span>
|
||
</section>
|
||
</div>
|
||
<style data-impeccable-css="ROOTIND">{\`@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }\`}</style>
|
||
<div data-impeccable-variant="1">
|
||
<section className="hook">
|
||
<span>nested text</span>
|
||
</section>
|
||
</div>
|
||
<div data-impeccable-variant="2" style={{ display: 'none' }}>
|
||
<section className="hook">
|
||
<span>variant two</span>
|
||
</section>
|
||
</div>
|
||
{/* impeccable-variants-end ROOTIND */}
|
||
</div>
|
||
`;
|
||
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,
|
||
/<div data-impeccable-variant="1" style=\{\{ display: 'contents' \}\}>\n <section className="hook">\n <span>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 <div>, 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 (
|
||
<main>
|
||
<aside className="card">
|
||
<h1 className="hero-title">Hero</h1>
|
||
</aside>
|
||
</main>
|
||
);
|
||
}`;
|
||
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 <h1> 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, /^ <aside className="card">$/m,
|
||
`<aside> opener must be at 6-space indent (was 8 before outer-indent fix), got:\n${after}`);
|
||
assert.match(after, /^ <h1 className="hero-title">Hero<\/h1>$/m,
|
||
`<h1> child must be at 8-space indent — relative depth preserved through wrap+discard. Got:\n${after}`);
|
||
assert.match(after, /^ <\/aside>$/m,
|
||
`</aside> closer must be back at 6-space indent. Got:\n${after}`);
|
||
});
|
||
|
||
it('expandReplaceRange handles multi-line self-closing <div /> inside the wrapped element', () => {
|
||
// Cursor Bugbot regression: per-line depth tracking in
|
||
// `expandReplaceRange` couldn't see across line boundaries, so a
|
||
// multi-line self-closing JSX `<div\n className="spacer"\n/>` got
|
||
// counted as +1 with no compensating -1. The wrapper's outer </div>
|
||
// never matched the depth-zero condition; replace-range stopped at
|
||
// block.end (the marker comment), leaving the wrapper's outer </div>
|
||
// orphaned in the file after accept/discard — and worse, an
|
||
// unrelated <div className="next-card"> right after the wrapper got
|
||
// its own </div> mis-counted as the wrapper close.
|
||
const tsx = `export default function App() {
|
||
return (
|
||
<main>
|
||
<aside className="card">
|
||
<h1>Hi</h1>
|
||
<div
|
||
className="spacer"
|
||
/>
|
||
<p>Body</p>
|
||
</aside>
|
||
<div className="next-card">After</div>
|
||
</main>
|
||
);
|
||
}`;
|
||
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 </div> 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 <div className="next-card">After</div> sibling
|
||
// must survive intact — Bugbot's worst-case scenario was the depth
|
||
// walk eating its </div> as the wrapper close.
|
||
assert.ok(after.includes('<div className="next-card">After</div>'),
|
||
`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, /<div\s*\n\s*className="spacer"\s*\n\s*\/>/m,
|
||
`multi-line self-closing <div /> 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 (
|
||
<main>
|
||
<div
|
||
className="impeccable-preview-shell"
|
||
${extraAttrs}
|
||
data-impeccable-variants="LONGOPEN"
|
||
data-impeccable-variant-count="2"
|
||
style={{ display: 'contents' }}
|
||
>
|
||
{/* impeccable-variants-start LONGOPEN */}
|
||
{/* Original */}
|
||
<div data-impeccable-variant="original">
|
||
<aside className="card">
|
||
<h1>Original</h1>
|
||
</aside>
|
||
</div>
|
||
{/* Variants: insert below this line */}
|
||
<div data-impeccable-variant="1"><aside className="card"><h1>Variant</h1></aside></div>
|
||
{/* impeccable-variants-end LONGOPEN */}
|
||
</div>
|
||
<div className="next-card">After</div>
|
||
</main>
|
||
);
|
||
}`;
|
||
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, /<aside className="card">\s*<h1>Original<\/h1>\s*<\/aside>/m);
|
||
assert.ok(after.includes('<div className="next-card">After</div>'));
|
||
});
|
||
|
||
it('expandReplaceRange ignores unrelated prior end markers while finding the current JSX wrapper', () => {
|
||
const tsx = `export default function App() {
|
||
return (
|
||
<main>
|
||
<div data-impeccable-variants="ACTIVE" data-impeccable-variant-count="2" style={{ display: 'contents' }}>
|
||
<div className="historical-marker-note">
|
||
{/* impeccable-variants-end OLD */}
|
||
</div>
|
||
{/* impeccable-variants-start ACTIVE */}
|
||
{/* Original */}
|
||
<div data-impeccable-variant="original">
|
||
<aside className="card">
|
||
<h1>Original</h1>
|
||
</aside>
|
||
</div>
|
||
{/* Variants: insert below this line */}
|
||
<div data-impeccable-variant="1"><aside className="card"><h1>Variant</h1></aside></div>
|
||
{/* impeccable-variants-end ACTIVE */}
|
||
</div>
|
||
<div className="next-card">After</div>
|
||
</main>
|
||
);
|
||
}`;
|
||
writeFileSync(join(tmp, 'App.tsx'), tsx);
|
||
|
||
const result = runAccept(tmp, ['--id', 'ACTIVE', '--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 ACTIVE/);
|
||
assert.doesNotMatch(after, /impeccable-variants-end OLD/);
|
||
assert.match(after, /<aside className="card">\s*<h1>Original<\/h1>\s*<\/aside>/m);
|
||
assert.ok(after.includes('<div className="next-card">After</div>'));
|
||
});
|
||
|
||
it('accept (no carbonize, raw HTML) restores at the original indent on JSX', () => {
|
||
// Manually craft a wrapped file in the JSX-marker-inside layout — this
|
||
// mirrors what wrap produces, but lets us exercise accept's indent
|
||
// logic without a full live cycle.
|
||
const tsx = `export default function App() {
|
||
return (
|
||
<main>
|
||
<div data-impeccable-variants="INDENTACC" data-impeccable-variant-count="3" style={{ display: "contents" }}>
|
||
{/* impeccable-variants-start INDENTACC */}
|
||
{/* Original */}
|
||
<div data-impeccable-variant="original">
|
||
<aside className="card">
|
||
<h1 className="hero-title">Hero</h1>
|
||
</aside>
|
||
</div>
|
||
{/* Variants: insert below this line */}
|
||
<div data-impeccable-variant="1"><aside className="card variant-one"><h1 className="hero-title">Hero</h1></aside></div>
|
||
{/* impeccable-variants-end INDENTACC */}
|
||
</div>
|
||
</main>
|
||
);
|
||
}`;
|
||
writeFileSync(join(tmp, 'App.tsx'), tsx);
|
||
|
||
runAccept(tmp, ['--id', 'INDENTACC', '--variant', '1']);
|
||
const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
|
||
// The accepted aside (variant-one) should land at 6-space indent, the
|
||
// same place the wrapper <div> sat — not 2 spaces deeper.
|
||
assert.match(after, /^ <aside className="card variant-one">/m,
|
||
`accepted <aside> must land at 6-space indent (the wrapper's level), got:\n${after}`);
|
||
});
|
||
|
||
// Discard must restore the original element after a self-closing <style />,
|
||
// proving extractOriginal also survives the style pattern.
|
||
it('discard restores the original element after a JSX self-closing <style />', () => {
|
||
const html = `<body>
|
||
<!-- impeccable-variants-start DISC -->
|
||
<div data-impeccable-variants="DISC" data-impeccable-variant-count="2" style="display: contents">
|
||
<div data-impeccable-variant="original"><p class="hook">ORIGINAL CONTENT</p></div>
|
||
<style data-impeccable-css="DISC" dangerouslySetInnerHTML={{ __html: '@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }' }} />
|
||
<div data-impeccable-variant="1"><p class="hook">variant one</p></div>
|
||
<div data-impeccable-variant="2" style="display: none"><p class="hook">variant two</p></div>
|
||
</div>
|
||
<!-- impeccable-variants-end DISC -->
|
||
</body>`;
|
||
writeFileSync(join(tmp, 'page.html'), html);
|
||
|
||
const result = runAccept(tmp, ['--id', 'DISC', '--discard']);
|
||
assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`);
|
||
|
||
const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||
assert.ok(after.includes('ORIGINAL CONTENT'), 'original restored');
|
||
assert.ok(!after.includes('impeccable-variants-start'), 'wrapper markers gone');
|
||
assert.ok(!after.includes('variant one'), 'variants dropped');
|
||
});
|
||
});
|
||
|
||
describe('live-accept — insert sessions', () => {
|
||
let tmp;
|
||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-insert-')); });
|
||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||
|
||
const insertHtml = (id) => `<main>
|
||
<section class="hero">Hero block</section>
|
||
<!-- impeccable-variants-start ${id} -->
|
||
<div data-impeccable-variants="${id}" data-impeccable-mode="insert" data-impeccable-variant-count="2" style="display: contents">
|
||
<!-- Variants: insert below this line -->
|
||
<style data-impeccable-css="${id}">@scope ([data-impeccable-variant="1"]) { .cta { color: red; } }</style>
|
||
<div data-impeccable-variant="1"><p class="cta">Variant one</p></div>
|
||
<div data-impeccable-variant="2" style="display: none"><p class="cta">Variant two</p></div>
|
||
</div>
|
||
<!-- impeccable-variants-end ${id} -->
|
||
<section class="footer">Footer</section>
|
||
</main>`;
|
||
|
||
it('discard removes an insert wrapper without touching anchor sections', () => {
|
||
writeFileSync(join(tmp, 'page.html'), insertHtml('insaaa01'));
|
||
const result = runAccept(tmp, ['--id', 'insaaa01', '--discard']);
|
||
assert.equal(result.handled, true, JSON.stringify(result));
|
||
const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||
assert.ok(after.includes('Hero block'));
|
||
assert.ok(after.includes('Footer'));
|
||
assert.ok(!after.includes('impeccable-variants-start'));
|
||
assert.ok(!after.includes('Variant one'));
|
||
});
|
||
|
||
it('accept keeps the chosen insert variant and drops the wrapper', () => {
|
||
writeFileSync(join(tmp, 'page.html'), insertHtml('insbbb02'));
|
||
const result = runAccept(tmp, ['--id', 'insbbb02', '--variant', '2']);
|
||
assert.equal(result.handled, true, JSON.stringify(result));
|
||
const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||
assert.ok(after.includes('Variant two'));
|
||
assert.ok(!after.includes('Variant one'));
|
||
assert.ok(!after.includes('impeccable-variants-start'));
|
||
assert.ok(after.includes('Hero block'));
|
||
assert.ok(after.includes('Footer'));
|
||
});
|
||
});
|