live-accept.test.mjs

  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});