1/**
2 * Tests for live-accept.mjs — the deterministic accept/discard helper.
3 * Run with: node --test tests/live-accept.test.mjs
4 */
5
6import { describe, it, beforeEach, afterEach } from 'node:test';
7import assert from 'node:assert/strict';
8import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
9import { dirname, join, resolve } from 'node:path';
10import { tmpdir } from 'node:os';
11import { fileURLToPath } from 'node:url';
12import { execFileSync, execSync } from 'node:child_process';
13
14const __dirname = dirname(fileURLToPath(import.meta.url));
15const ACCEPT = resolve(__dirname, '..', 'skill/scripts/live-accept.mjs');
16
17function runAccept(cwd, args) {
18 try {
19 const out = execFileSync('node', [ACCEPT, ...args], {
20 cwd,
21 encoding: 'utf-8',
22 stdio: ['ignore', 'pipe', 'pipe'],
23 });
24 return JSON.parse(out.trim());
25 } catch (err) {
26 const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || '';
27 return JSON.parse(body || '{}');
28 }
29}
30
31describe('live-accept — style-element edge cases', () => {
32 let tmp;
33 beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-test-')); });
34 afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
35
36 // Historical bug: extractVariant flipped into "inStyle" mode on <style and
37 // scanned for </style> line-by-line. JSX self-closing <style ... /> has no
38 // separate closer, so it got stuck forever and missed data-impeccable-variant
39 // divs that came after.
40 it('finds the accepted variant after a JSX self-closing <style /> block', () => {
41 const html = `<body>
42 <!-- impeccable-variants-start SELFC -->
43 <div data-impeccable-variants="SELFC" data-impeccable-variant-count="3" style="display: contents">
44 <div data-impeccable-variant="original">
45 <p class="hook">original text</p>
46 </div>
47 <style data-impeccable-css="SELFC" dangerouslySetInnerHTML={{ __html: '@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }' }} />
48 <div data-impeccable-variant="1">
49 <p class="hook">variant one</p>
50 </div>
51 <div data-impeccable-variant="2" style="display: none">
52 <p class="hook">variant two</p>
53 </div>
54 <div data-impeccable-variant="3" style="display: none">
55 <p class="hook">variant three</p>
56 </div>
57 </div>
58 <!-- impeccable-variants-end SELFC -->
59</body>`;
60 writeFileSync(join(tmp, 'page.html'), html);
61
62 const result = runAccept(tmp, ['--id', 'SELFC', '--variant', '2']);
63 assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`);
64
65 const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
66 // Self-closing style has no extractable CSS body, so there's nothing to carbonize —
67 // no carbonize block, no data-impeccable-variant wrapper (it would serve no purpose).
68 assert.ok(!after.includes('impeccable-carbonize-start'), 'no carbonize block (self-closing style has no body)');
69 assert.ok(!after.includes('impeccable-variants-start'), 'variant markers removed');
70 assert.ok(after.includes('variant two'), 'variant 2 content kept');
71 assert.ok(!after.includes('variant three'), 'other variant content dropped');
72 assert.ok(!after.includes('variant one'), 'other variant content dropped');
73 assert.ok(!after.includes('original text'), 'original content dropped');
74 });
75
76 // Variant: same-line <style>…</style> block should also be treated as a
77 // single skipped unit; the line has both open and close tags.
78 it('finds the accepted variant after a single-line <style>…</style> block', () => {
79 const html = `<body>
80 <!-- impeccable-variants-start ONELINE -->
81 <div data-impeccable-variants="ONELINE" data-impeccable-variant-count="3" style="display: contents">
82 <div data-impeccable-variant="original"><p class="hook">original</p></div>
83 <style data-impeccable-css="ONELINE">@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }</style>
84 <div data-impeccable-variant="1"><p class="hook">variant one</p></div>
85 <div data-impeccable-variant="2" style="display: none"><p class="hook">variant two</p></div>
86 <div data-impeccable-variant="3" style="display: none"><p class="hook">variant three</p></div>
87 </div>
88 <!-- impeccable-variants-end ONELINE -->
89</body>`;
90 writeFileSync(join(tmp, 'page.html'), html);
91
92 const result = runAccept(tmp, ['--id', 'ONELINE', '--variant', '3']);
93 assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`);
94
95 const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
96 assert.ok(after.includes('data-impeccable-variant="3"'), 'accepted wrapper for variant 3 present');
97 assert.ok(after.includes('variant three'), 'variant 3 content kept');
98 assert.ok(!after.includes('variant two'), 'other variant content dropped');
99 });
100
101 // Baseline: the standard multi-line <style>...</style> case must keep working.
102 it('finds the accepted variant after a multi-line <style>…</style> block (regression baseline)', () => {
103 const html = `<body>
104 <!-- impeccable-variants-start MULTI -->
105 <div data-impeccable-variants="MULTI" data-impeccable-variant-count="3" style="display: contents">
106 <div data-impeccable-variant="original"><p class="hook">original</p></div>
107 <style data-impeccable-css="MULTI">
108 @scope ([data-impeccable-variant="1"]) { .hook { color: red; } }
109 @scope ([data-impeccable-variant="2"]) { .hook { color: green; } }
110 </style>
111 <div data-impeccable-variant="1"><p class="hook">variant one</p></div>
112 <div data-impeccable-variant="2" style="display: none"><p class="hook">variant two</p></div>
113 </div>
114 <!-- impeccable-variants-end MULTI -->
115</body>`;
116 writeFileSync(join(tmp, 'page.html'), html);
117
118 const result = runAccept(tmp, ['--id', 'MULTI', '--variant', '1']);
119 assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`);
120
121 const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
122 assert.ok(after.includes('data-impeccable-variant="1"'), 'accepted wrapper for variant 1 present');
123 assert.ok(after.includes('variant one'), 'variant 1 content kept');
124 });
125
126 // Regression: the agent writes JSX <style>{`…`}</style> and live-accept's
127 // extractCss used to capture the `{` … `` ` ``}` template-literal punctuation
128 // as CSS content. handleAccept then re-wrapped with another `{` …
129 // `` ` ``}`, producing nested template literals (`<style>{`{`@scope…`}`}`)
130 // that oxc rejects with "Expected `}` but found `@`". extractCss must
131 // strip the JSX wrap regardless of where the agent placed it.
132 it('carbonize does not double-wrap when the variants block uses JSX template literals on their own lines', () => {
133 const tsx = `export default function App() {\n` +
134 ` return (\n` +
135 ` <main>\n` +
136 ` <>\n` +
137 ` {/* impeccable-variants-start TPL */}\n` +
138 ` <div data-impeccable-variants="TPL" data-impeccable-variant-count="2" style={{ display: 'contents' }}>\n` +
139 ` <div data-impeccable-variant="original"><p className="hook">orig</p></div>\n` +
140 ` <style data-impeccable-css="TPL">\n` +
141 " {`\n" +
142 ` @scope ([data-impeccable-variant="1"]) { .hook { color: red; } }\n` +
143 ` @scope ([data-impeccable-variant="2"]) { .hook { color: green; } }\n` +
144 " `}\n" +
145 ` </style>\n` +
146 ` <div data-impeccable-variant="1"><p className="hook">variant one</p></div>\n` +
147 ` <div data-impeccable-variant="2" style={{ display: 'none' }}><p className="hook">variant two</p></div>\n` +
148 ` </div>\n` +
149 ` {/* impeccable-variants-end TPL */}\n` +
150 ` </>\n` +
151 ` </main>\n` +
152 ` );\n` +
153 `}\n`;
154 writeFileSync(join(tmp, 'App.tsx'), tsx);
155
156 const result = runAccept(tmp, ['--id', 'TPL', '--variant', '1']);
157 assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`);
158
159 const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
160 // Exactly one `{` opener after the carbonized <style ...> tag — not two.
161 const carbonStyleMatch = after.match(/<style data-impeccable-css="TPL">([\s\S]*?)<\/style>/);
162 assert.ok(carbonStyleMatch, 'carbonize <style> block present');
163 const inner = carbonStyleMatch[1];
164 // Inner must open with one `{` ... and end with one ` `` ... — no nesting.
165 const openCount = (inner.match(/\{`/g) || []).length;
166 const closeCount = (inner.match(/`\}/g) || []).length;
167 assert.equal(openCount, 1, `expected exactly one {\` opener, got ${openCount}`);
168 assert.equal(closeCount, 1, `expected exactly one \`} closer, got ${closeCount}`);
169 // CSS content survived intact.
170 assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept');
171 });
172
173 // Same shape, but the agent put `{`` and ``\`}` attached to first/last CSS
174 // lines instead of on dedicated lines. Tests the inline-strip branch.
175 it('carbonize does not double-wrap when JSX template-literal punctuation hugs the CSS lines', () => {
176 const tsx = `export default function App() {\n` +
177 ` return (\n` +
178 ` <main>\n` +
179 ` <>\n` +
180 ` {/* impeccable-variants-start INLINE */}\n` +
181 ` <div data-impeccable-variants="INLINE" data-impeccable-variant-count="2" style={{ display: 'contents' }}>\n` +
182 ` <div data-impeccable-variant="original"><p className="hook">orig</p></div>\n` +
183 ` <style data-impeccable-css="INLINE">\n` +
184 " {`@scope ([data-impeccable-variant=\"1\"]) { .hook { color: red; } }\n" +
185 " @scope ([data-impeccable-variant=\"2\"]) { .hook { color: green; } }`}\n" +
186 ` </style>\n` +
187 ` <div data-impeccable-variant="1"><p className="hook">variant one</p></div>\n` +
188 ` <div data-impeccable-variant="2" style={{ display: 'none' }}><p className="hook">variant two</p></div>\n` +
189 ` </div>\n` +
190 ` {/* impeccable-variants-end INLINE */}\n` +
191 ` </>\n` +
192 ` </main>\n` +
193 ` );\n` +
194 `}\n`;
195 writeFileSync(join(tmp, 'App.tsx'), tsx);
196
197 const result = runAccept(tmp, ['--id', 'INLINE', '--variant', '1']);
198 assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`);
199
200 const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
201 const inner = after.match(/<style data-impeccable-css="INLINE">([\s\S]*?)<\/style>/)[1];
202 const openCount = (inner.match(/\{`/g) || []).length;
203 const closeCount = (inner.match(/`\}/g) || []).length;
204 assert.equal(openCount, 1, `expected one {\` opener, got ${openCount}`);
205 assert.equal(closeCount, 1, `expected one \`} closer, got ${closeCount}`);
206 assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept');
207 });
208
209 // Cursor Bugbot regression (PR #118 review): the JSX wrapper places
210 // marker comments INSIDE the outer <div>, so block.start sits 2 spaces
211 // deeper than the original element. Using block.start as the deindent
212 // base on JSX accept/discard pushes every restored line 2 spaces too far
213 // right. The fix anchors the indent on `replaceRange.start` (the outer
214 // wrapper line), which is at the original element's indent level for
215 // both HTML and JSX.
216 it('discard restores JSX content at the original indent (no 2-space drift from marker-inside layout)', () => {
217 // Run the real wrap CLI so we exercise the JSX-marker-inside-wrapper
218 // layout end to end, not a hand-rolled approximation.
219 const tsx = `export default function App() {
220 return (
221 <main>
222 <aside className="card">
223 <h1 className="hero-title">Hero</h1>
224 </aside>
225 </main>
226 );
227}`;
228 writeFileSync(join(tmp, 'App.tsx'), tsx);
229
230 execSync(
231 `node skill/scripts/live-wrap.mjs --id INDENTDISC --count 3 --classes "card" --tag "aside" --file "${join(tmp, 'App.tsx')}"`,
232 { cwd: process.cwd(), encoding: 'utf-8' }
233 );
234
235 runAccept(tmp, ['--id', 'INDENTDISC', '--discard']);
236 const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
237 // The aside opener should land at exactly 6 spaces — same as the
238 // original — and the <h1> child at 8 (preserved relative depth).
239 // The earlier 6/6/6 collapse was caused by `originalLines.map(l =>
240 // indent + ' ' + l.trimStart())` in live-wrap stripping ALL
241 // leading whitespace before reindenting; the fix strips only the
242 // COMMON minimum so the relative structure is preserved.
243 assert.match(after, /^ <aside className="card">$/m,
244 `<aside> opener must be at 6-space indent (was 8 before outer-indent fix), got:\n${after}`);
245 assert.match(after, /^ <h1 className="hero-title">Hero<\/h1>$/m,
246 `<h1> child must be at 8-space indent — relative depth preserved through wrap+discard. Got:\n${after}`);
247 assert.match(after, /^ <\/aside>$/m,
248 `</aside> closer must be back at 6-space indent. Got:\n${after}`);
249 });
250
251 it('expandReplaceRange handles multi-line self-closing <div /> inside the wrapped element', () => {
252 // Cursor Bugbot regression: per-line depth tracking in
253 // `expandReplaceRange` couldn't see across line boundaries, so a
254 // multi-line self-closing JSX `<div\n className="spacer"\n/>` got
255 // counted as +1 with no compensating -1. The wrapper's outer </div>
256 // never matched the depth-zero condition; replace-range stopped at
257 // block.end (the marker comment), leaving the wrapper's outer </div>
258 // orphaned in the file after accept/discard — and worse, an
259 // unrelated <div className="next-card"> right after the wrapper got
260 // its own </div> mis-counted as the wrapper close.
261 const tsx = `export default function App() {
262 return (
263 <main>
264 <aside className="card">
265 <h1>Hi</h1>
266 <div
267 className="spacer"
268 />
269 <p>Body</p>
270 </aside>
271 <div className="next-card">After</div>
272 </main>
273 );
274}`;
275 writeFileSync(join(tmp, 'App.tsx'), tsx);
276
277 execSync(
278 `node skill/scripts/live-wrap.mjs --id MULTILINESC --count 3 --classes "card" --tag "aside" --file "${join(tmp, 'App.tsx')}"`,
279 { cwd: process.cwd(), encoding: 'utf-8' }
280 );
281
282 const result = runAccept(tmp, ['--id', 'MULTILINESC', '--discard']);
283 assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`);
284
285 const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
286 // The wrapper scaffold must be fully gone — no orphan </div> from
287 // the outer wrapper, and no impeccable markers/data attributes.
288 assert.ok(!after.includes('data-impeccable-variants'),
289 `outer wrapper div must be fully removed; got:\n${after}`);
290 assert.ok(!after.includes('data-impeccable-variant'),
291 `original-div wrapper must be fully removed; got:\n${after}`);
292 assert.ok(!after.includes('impeccable-variants-start'),
293 `start marker must be removed; got:\n${after}`);
294 // The unrelated <div className="next-card">After</div> sibling
295 // must survive intact — Bugbot's worst-case scenario was the depth
296 // walk eating its </div> as the wrapper close.
297 assert.ok(after.includes('<div className="next-card">After</div>'),
298 `unrelated next-card sibling must be preserved; got:\n${after}`);
299 // The multi-line self-closing div inside the original element must
300 // survive too.
301 assert.match(after, /<div\s*\n\s*className="spacer"\s*\n\s*\/>/m,
302 `multi-line self-closing <div /> inside original must survive; got:\n${after}`);
303 });
304
305 it('accept (no carbonize, raw HTML) restores at the original indent on JSX', () => {
306 // Manually craft a wrapped file in the JSX-marker-inside layout — this
307 // mirrors what wrap produces, but lets us exercise accept's indent
308 // logic without a full live cycle.
309 const tsx = `export default function App() {
310 return (
311 <main>
312 <div data-impeccable-variants="INDENTACC" data-impeccable-variant-count="3" style={{ display: "contents" }}>
313 {/* impeccable-variants-start INDENTACC */}
314 {/* Original */}
315 <div data-impeccable-variant="original">
316 <aside className="card">
317 <h1 className="hero-title">Hero</h1>
318 </aside>
319 </div>
320 {/* Variants: insert below this line */}
321 <div data-impeccable-variant="1"><aside className="card variant-one"><h1 className="hero-title">Hero</h1></aside></div>
322 {/* impeccable-variants-end INDENTACC */}
323 </div>
324 </main>
325 );
326}`;
327 writeFileSync(join(tmp, 'App.tsx'), tsx);
328
329 runAccept(tmp, ['--id', 'INDENTACC', '--variant', '1']);
330 const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
331 // The accepted aside (variant-one) should land at 6-space indent, the
332 // same place the wrapper <div> sat — not 2 spaces deeper.
333 assert.match(after, /^ <aside className="card variant-one">/m,
334 `accepted <aside> must land at 6-space indent (the wrapper's level), got:\n${after}`);
335 });
336
337 // Discard must restore the original element after a self-closing <style />,
338 // proving extractOriginal also survives the style pattern.
339 it('discard restores the original element after a JSX self-closing <style />', () => {
340 const html = `<body>
341 <!-- impeccable-variants-start DISC -->
342 <div data-impeccable-variants="DISC" data-impeccable-variant-count="2" style="display: contents">
343 <div data-impeccable-variant="original"><p class="hook">ORIGINAL CONTENT</p></div>
344 <style data-impeccable-css="DISC" dangerouslySetInnerHTML={{ __html: '@scope ([data-impeccable-variant="1"]) { .hook { color: red; } }' }} />
345 <div data-impeccable-variant="1"><p class="hook">variant one</p></div>
346 <div data-impeccable-variant="2" style="display: none"><p class="hook">variant two</p></div>
347 </div>
348 <!-- impeccable-variants-end DISC -->
349</body>`;
350 writeFileSync(join(tmp, 'page.html'), html);
351
352 const result = runAccept(tmp, ['--id', 'DISC', '--discard']);
353 assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`);
354
355 const after = readFileSync(join(tmp, 'page.html'), 'utf-8');
356 assert.ok(after.includes('ORIGINAL CONTENT'), 'original restored');
357 assert.ok(!after.includes('impeccable-variants-start'), 'wrapper markers gone');
358 assert.ok(!after.includes('variant one'), 'variants dropped');
359 });
360});