live-wrap.test.mjs

  1/**
  2 * Tests for the live-wrap CLI helper.
  3 * Run with: node --test tests/live-wrap.test.mjs
  4 */
  5
  6import { describe, it, beforeEach, afterEach } from 'node:test';
  7import assert from 'node:assert/strict';
  8import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
  9import { join } from 'node:path';
 10import { tmpdir } from 'node:os';
 11import { execSync } from 'node:child_process';
 12
 13import {
 14  buildSearchQueries,
 15  findElement,
 16  findClosingLine,
 17  detectCommentSyntax,
 18} from '../skill/scripts/live-wrap.mjs';
 19
 20// ---------------------------------------------------------------------------
 21// Unit tests: pure functions
 22// ---------------------------------------------------------------------------
 23
 24describe('detectCommentSyntax', () => {
 25  it('returns HTML comments for .html files', () => {
 26    const result = detectCommentSyntax('index.html');
 27    assert.equal(result.open, '<!--');
 28    assert.equal(result.close, '-->');
 29  });
 30
 31  it('returns JSX comments for .jsx files', () => {
 32    const result = detectCommentSyntax('App.jsx');
 33    assert.equal(result.open, '{/*');
 34    assert.equal(result.close, '*/}');
 35  });
 36
 37  it('returns JSX comments for .tsx files', () => {
 38    const result = detectCommentSyntax('component.tsx');
 39    assert.equal(result.open, '{/*');
 40    assert.equal(result.close, '*/}');
 41  });
 42
 43  it('returns HTML comments for .vue files', () => {
 44    const result = detectCommentSyntax('App.vue');
 45    assert.equal(result.open, '<!--');
 46    assert.equal(result.close, '-->');
 47  });
 48
 49  it('returns HTML comments for .svelte files', () => {
 50    const result = detectCommentSyntax('Page.svelte');
 51    assert.equal(result.open, '<!--');
 52    assert.equal(result.close, '-->');
 53  });
 54});
 55
 56describe('buildSearchQueries', () => {
 57  it('prioritizes ID over classes', () => {
 58    const queries = buildSearchQueries('hero', 'hero-section,dark', 'section', null);
 59    assert.equal(queries[0], 'id="hero"');
 60  });
 61
 62  it('includes full class match for multi-class elements', () => {
 63    const queries = buildSearchQueries(null, 'hero-section,dark-theme', 'div', null);
 64    assert.ok(queries.some(q => q === 'class="hero-section dark-theme"'));
 65  });
 66
 67  it('includes the most distinctive single class (longest)', () => {
 68    const queries = buildSearchQueries(null, 'btn,hero-combined-left', null, null);
 69    assert.ok(queries.some(q => q === 'hero-combined-left'));
 70  });
 71
 72  it('includes tag+class combo', () => {
 73    const queries = buildSearchQueries(null, 'hero-section', 'section', null);
 74    assert.ok(queries.some(q => q === '<section class="hero-section'));
 75  });
 76
 77  it('includes raw fallback query', () => {
 78    const queries = buildSearchQueries(null, null, null, 'Welcome to our app');
 79    assert.deepEqual(queries, ['Welcome to our app']);
 80  });
 81
 82  it('returns all query types when everything is provided', () => {
 83    const queries = buildSearchQueries('main', 'container,wide', 'div', 'fallback');
 84    assert.ok(queries.length >= 4);
 85    assert.equal(queries[0], 'id="main"');
 86    assert.ok(queries.includes('fallback'));
 87  });
 88});
 89
 90describe('findElement', () => {
 91  it('finds an element by class name', () => {
 92    const lines = [
 93      '<html>',
 94      '<body>',
 95      '  <div class="hero">',
 96      '    <h1>Hello</h1>',
 97      '  </div>',
 98      '</body>',
 99      '</html>',
100    ];
101    const result = findElement(lines, 'hero');
102    assert.ok(result);
103    assert.equal(result.startLine, 2);
104    assert.equal(result.endLine, 4);
105  });
106
107  it('finds an element by ID', () => {
108    const lines = [
109      '<section id="features">',
110      '  <p>Content</p>',
111      '</section>',
112    ];
113    const result = findElement(lines, 'id="features"');
114    assert.ok(result);
115    assert.equal(result.startLine, 0);
116    assert.equal(result.endLine, 2);
117  });
118
119  it('returns null when element is not found', () => {
120    const lines = ['<div>hello</div>'];
121    const result = findElement(lines, 'nonexistent');
122    assert.equal(result, null);
123  });
124
125  it('skips comments containing the query', () => {
126    const lines = [
127      '<!-- hero section -->',
128      '<div class="hero">',
129      '  <p>Content</p>',
130      '</div>',
131    ];
132    const result = findElement(lines, 'hero');
133    assert.ok(result);
134    assert.equal(result.startLine, 1); // skips the comment on line 0
135  });
136
137  it('skips lines that contain data-impeccable-variant', () => {
138    const lines = [
139      '<div class="hero" data-impeccable-variant="original">Old</div>',
140      '<div class="hero">Real</div>',
141    ];
142    const result = findElement(lines, 'hero');
143    assert.ok(result);
144    assert.equal(result.startLine, 1);
145  });
146});
147
148describe('findClosingLine', () => {
149  it('finds the closing tag on the same line', () => {
150    const lines = ['<p>Hello</p>'];
151    assert.equal(findClosingLine(lines, 0), 0);
152  });
153
154  it('finds the closing tag across multiple lines', () => {
155    const lines = [
156      '<div>',
157      '  <p>Hello</p>',
158      '</div>',
159    ];
160    assert.equal(findClosingLine(lines, 0), 2);
161  });
162
163  it('handles nested tags of the same type', () => {
164    const lines = [
165      '<div class="outer">',
166      '  <div class="inner">',
167      '    <p>Content</p>',
168      '  </div>',
169      '</div>',
170    ];
171    assert.equal(findClosingLine(lines, 0), 4);
172  });
173
174  it('handles deeply nested structures', () => {
175    const lines = [
176      '<section>',
177      '  <div>',
178      '    <div>',
179      '      <span>text</span>',
180      '    </div>',
181      '  </div>',
182      '</section>',
183    ];
184    assert.equal(findClosingLine(lines, 0), 6);
185  });
186
187  it('handles self-closing tags', () => {
188    const lines = [
189      '<div>',
190      '  <img src="test.png" />',
191      '  <br />',
192      '</div>',
193    ];
194    assert.equal(findClosingLine(lines, 0), 3);
195  });
196});
197
198// ---------------------------------------------------------------------------
199// Integration tests: full wrap CLI on fixture files
200// ---------------------------------------------------------------------------
201
202describe('wrapCli integration', () => {
203  let tmp;
204
205  beforeEach(() => {
206    tmp = mkdtempSync(join(tmpdir(), 'impeccable-wrap-test-'));
207  });
208
209  afterEach(() => {
210    rmSync(tmp, { recursive: true, force: true });
211  });
212
213  it('wraps an HTML element by class name', () => {
214    const html = `<!DOCTYPE html>
215<html>
216<body>
217  <div class="hero-section">
218    <h1>Hello World</h1>
219    <p>Welcome to our site.</p>
220  </div>
221</body>
222</html>`;
223    writeFileSync(join(tmp, 'index.html'), html);
224
225    const result = JSON.parse(execSync(
226      `node skill/scripts/live-wrap.mjs --id test123 --count 3 --classes "hero-section" --file "${join(tmp, 'index.html')}"`,
227      { cwd: process.cwd(), encoding: 'utf-8' }
228    ));
229
230    // The file path is relative to cwd, so it may be a relative path to the tmp dir
231    assert.ok(result.file.endsWith('index.html'));
232    assert.ok(result.insertLine > 0);
233    assert.equal(result.commentSyntax.open, '<!--');
234
235    // Verify the file was modified correctly
236    const modified = readFileSync(join(tmp, 'index.html'), 'utf-8');
237    assert.ok(modified.includes('data-impeccable-variants="test123"'));
238    assert.ok(modified.includes('data-impeccable-variant-count="3"'));
239    assert.ok(modified.includes('data-impeccable-variant="original"'));
240    assert.ok(modified.includes('display: contents'));
241    assert.ok(modified.includes('impeccable-variants-start test123'));
242    assert.ok(modified.includes('impeccable-variants-end test123'));
243    // Original should NOT be hidden (stays visible until variants arrive)
244    assert.ok(!modified.includes('data-impeccable-variant="original" style="display: none"'));
245  });
246
247  it('wraps a JSX element and uses JSX comment syntax', () => {
248    const jsx = `export default function App() {
249  return (
250    <main>
251      <section className="hero">
252        <h1>Hello</h1>
253      </section>
254    </main>
255  );
256}`;
257    writeFileSync(join(tmp, 'App.jsx'), jsx);
258
259    const result = JSON.parse(execSync(
260      `node skill/scripts/live-wrap.mjs --id jsx123 --count 2 --classes "hero" --file "${join(tmp, 'App.jsx')}"`,
261      { cwd: process.cwd(), encoding: 'utf-8' }
262    ));
263
264    assert.equal(result.commentSyntax.open, '{/*');
265    assert.equal(result.commentSyntax.close, '*/}');
266
267    const modified = readFileSync(join(tmp, 'App.jsx'), 'utf-8');
268    assert.ok(modified.includes('{/* impeccable-variants-start jsx123'));
269    assert.ok(modified.includes('data-impeccable-variant-count="2"'));
270  });
271
272  it('finds element by ID when --element-id is used', () => {
273    const html = `<html><body>
274<div id="pricing">
275  <h2>Pricing</h2>
276  <p>Plans start at $10/mo.</p>
277</div>
278</body></html>`;
279    writeFileSync(join(tmp, 'page.html'), html);
280
281    const result = JSON.parse(execSync(
282      `node skill/scripts/live-wrap.mjs --id id123 --count 2 --element-id "pricing" --file "${join(tmp, 'page.html')}"`,
283      { cwd: process.cwd(), encoding: 'utf-8' }
284    ));
285
286    const modified = readFileSync(join(tmp, 'page.html'), 'utf-8');
287    assert.ok(modified.includes('data-impeccable-variants="id123"'));
288    // The original pricing div should be inside the wrapper
289    assert.ok(modified.includes('id="pricing"'));
290  });
291
292  it('exits with error when element is not found', () => {
293    writeFileSync(join(tmp, 'empty.html'), '<html><body><p>No match here</p></body></html>');
294
295    try {
296      execSync(
297        `node skill/scripts/live-wrap.mjs --id err123 --count 2 --classes "nonexistent" --file "${join(tmp, 'empty.html')}"`,
298        { cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' }
299      );
300      assert.fail('Should have exited with error');
301    } catch (err) {
302      assert.ok(err.status !== 0, 'Should exit with non-zero status');
303      assert.ok(err.stderr.includes('error') || err.stderr.includes('Could not'), 'Should print error message');
304    }
305  });
306
307  it('preserves surrounding content when wrapping', () => {
308    const html = `<div class="before">Before</div>
309<div class="target">
310  <span>Target content</span>
311</div>
312<div class="after">After</div>`;
313    writeFileSync(join(tmp, 'preserve.html'), html);
314
315    execSync(
316      `node skill/scripts/live-wrap.mjs --id pres123 --count 2 --classes "target" --file "${join(tmp, 'preserve.html')}"`,
317      { cwd: process.cwd(), encoding: 'utf-8' }
318    );
319
320    const modified = readFileSync(join(tmp, 'preserve.html'), 'utf-8');
321    assert.ok(modified.includes('class="before"'));
322    assert.ok(modified.includes('class="after"'));
323    assert.ok(modified.includes('data-impeccable-variants="pres123"'));
324  });
325
326  it('reports scoped CSS authoring as the default live style contract', () => {
327    const html = `<section class="hero-shell">
328  <h1>Plain title</h1>
329</section>`;
330    writeFileSync(join(tmp, 'plain.html'), html);
331
332    const result = JSON.parse(execSync(
333      `node skill/scripts/live-wrap.mjs --id scopedCss --count 2 --classes "hero-shell" --tag "section" --file "${join(tmp, 'plain.html')}"`,
334      { cwd: process.cwd(), encoding: 'utf-8' }
335    ));
336
337    assert.equal(result.styleMode, 'scoped');
338    assert.equal(result.cssAuthoring.mode, 'scoped');
339    assert.equal(result.cssAuthoring.strategy, 'scope-rule');
340    assert.equal(result.cssAuthoring.styleTag, '<style data-impeccable-css="SESSION_ID">');
341    assert.match(result.cssAuthoring.rulePattern, /@scope/);
342    assert.ok(
343      result.cssAuthoring.forbidden.some((item) => item.includes('is:inline')),
344      'scoped mode should explicitly reject file-specific style tag attributes',
345    );
346  });
347
348  it('reports Astro files need global prefixed live CSS instead of raw @scope', () => {
349    const astro = `---
350const title = 'Astro title';
351---
352<section class="hero-shell">
353  <h1>{title}</h1>
354</section>`;
355    writeFileSync(join(tmp, 'Hero.astro'), astro);
356
357    const result = JSON.parse(execSync(
358      `node skill/scripts/live-wrap.mjs --id astroCss --count 3 --classes "hero-shell" --tag "section" --file "${join(tmp, 'Hero.astro')}"`,
359      { cwd: process.cwd(), encoding: 'utf-8' }
360    ));
361
362    assert.equal(
363      result.styleMode,
364      'astro-global-prefixed',
365      'event=live_wrap.astro_css_mode actor=agent operation=wrap_astro_file risk=astro_scopes_preview_css_away expected=styleMode astro-global-prefixed actual=' + result.styleMode + ' suggestion=inspect live-wrap output metadata for .astro files'
366    );
367    assert.deepEqual(result.cssSelectorPrefixExamples, [
368      '[data-impeccable-variant="1"]',
369      '[data-impeccable-variant="2"]',
370      '[data-impeccable-variant="3"]',
371    ]);
372    assert.equal(result.cssAuthoring.mode, 'astro-global-prefixed');
373    assert.equal(result.cssAuthoring.strategy, 'global-prefixed');
374    assert.equal(result.cssAuthoring.styleTag, '<style is:inline data-impeccable-css="SESSION_ID">');
375    assert.match(result.cssAuthoring.rulePattern, /^\[data-impeccable-variant="N"\]/);
376    assert.ok(
377      result.cssAuthoring.forbidden.some((item) => item.includes('@scope')),
378      'Astro-prefixed mode should explicitly reject @scope',
379    );
380  });
381});
382
383// ---------------------------------------------------------------------------
384// Regression tests from real-world failures (EAC report, 2026-04)
385// ---------------------------------------------------------------------------
386
387describe('live-wrap — JSX / TSX correctness', () => {
388  let tmp;
389  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-wrap-jsx-')); });
390  afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
391
392  it('wraps the correct <section> when a class collides with a multi-line tag elsewhere', () => {
393    // Decoy section: multi-line JSX with `organic-sand-surface` inside className
394    // but NOT the full `py-20 lg:py-24` combo.
395    // Target section: same class token on one line, together with py-20 lg:py-24.
396    //
397    // Bug: substring matcher lands on the decoy's className continuation line,
398    // mangling the decoy tag and missing the real target entirely.
399    const tsx = `export default function Page() {
400  return (
401    <main>
402      <section
403        className="organic-sand-surface public-arc-top-section relative z-10 pb-16 lg:pb-20"
404        id="marketplace-intro"
405      >
406        <h2>Intro</h2>
407      </section>
408
409      <section className="organic-sand-surface py-20 lg:py-24">
410        <h2>Target</h2>
411      </section>
412    </main>
413  );
414}`;
415    writeFileSync(join(tmp, 'page.tsx'), tsx);
416
417    execSync(
418      `node skill/scripts/live-wrap.mjs --id wrapA --count 3 --classes "organic-sand-surface,py-20,lg:py-24" --tag "section" --file "${join(tmp, 'page.tsx')}"`,
419      { cwd: process.cwd(), encoding: 'utf-8' }
420    );
421
422    const modified = readFileSync(join(tmp, 'page.tsx'), 'utf-8');
423
424    // Wrapper landed somewhere.
425    assert.ok(modified.includes('impeccable-variants-start wrapA'), 'wrapper was created');
426
427    // Decoy section survives intact — all three of its lines still present in order.
428    const decoyIntact =
429      /<section\s*\n\s*className="organic-sand-surface public-arc-top-section/.test(modified) &&
430      /id="marketplace-intro"/.test(modified);
431    assert.ok(decoyIntact, 'decoy section opening tag was not mangled');
432
433    // Target section sits inside the original variant wrapper.
434    const originalMatch = modified.match(/data-impeccable-variant="original"[^>]*>([\s\S]*?)\s*<\/div>/);
435    assert.ok(originalMatch, 'original variant wrapper exists');
436    const inside = originalMatch[1];
437    assert.ok(inside.includes('py-20 lg:py-24'), 'target section (with py-20 lg:py-24) is inside original wrapper');
438    assert.ok(!inside.includes('public-arc-top-section'), 'decoy section is NOT inside original wrapper');
439  });
440
441  it('emits JSX-safe style attribute ({{ }}) in .tsx files', () => {
442    const tsx = `export default function App() {
443  return (
444    <main>
445      <section className="target">
446        <h1>Hi</h1>
447      </section>
448    </main>
449  );
450}`;
451    writeFileSync(join(tmp, 'App.tsx'), tsx);
452
453    execSync(
454      `node skill/scripts/live-wrap.mjs --id jsxStyle --count 3 --classes "target" --tag "section" --file "${join(tmp, 'App.tsx')}"`,
455      { cwd: process.cwd(), encoding: 'utf-8' }
456    );
457
458    const modified = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
459
460    // HTML-attribute style="..." is invalid JSX (parses then type-errors in strict setups).
461    assert.ok(
462      !/style\s*=\s*"display:\s*contents"/.test(modified),
463      'no HTML-style style attribute on outer wrapper'
464    );
465    // JSX-safe object syntax instead.
466    assert.ok(
467      /style=\{\{\s*display:\s*["']contents["']\s*\}\}/.test(modified),
468      'outer wrapper uses JSX style={{ display: "contents" }}'
469    );
470  });
471
472  it('finds elements via className= (React) when the exact class combo is unique there', () => {
473    // Both divs contain `target-marker`, but only one shares `shared-class` with it.
474    // A substring-only search would hit the decoy first; the full className match
475    // disambiguates — requires the query generator to emit className="..." too.
476    const tsx = `export default function Page() {
477  return (
478    <main>
479      <div className="extra-class target-marker">Decoy</div>
480      <div className="shared-class target-marker">Target</div>
481    </main>
482  );
483}`;
484    writeFileSync(join(tmp, 'Page.tsx'), tsx);
485
486    execSync(
487      `node skill/scripts/live-wrap.mjs --id classNameA --count 3 --classes "shared-class,target-marker" --tag "div" --file "${join(tmp, 'Page.tsx')}"`,
488      { cwd: process.cwd(), encoding: 'utf-8' }
489    );
490
491    const modified = readFileSync(join(tmp, 'Page.tsx'), 'utf-8');
492
493    const originalMatch = modified.match(/data-impeccable-variant="original"[^>]*>([\s\S]*?)\s*<\/div>/);
494    assert.ok(originalMatch, 'original variant wrapper exists');
495    const inside = originalMatch[1];
496    assert.ok(inside.includes('shared-class target-marker'), 'correct target wrapped');
497    assert.ok(!inside.includes('extra-class'), 'decoy not wrapped');
498  });
499
500  it('keeps the JSX wrapper single-rooted by tucking marker comments INSIDE the outer <div>', () => {
501    // Replacing one JSX element with [comment, <div>, comment] yields three
502    // adjacent siblings, which Vite's oxc rejects with "Adjacent JSX
503    // elements must be wrapped in an enclosing tag." A Fragment `<></>`
504    // would solve adjacency but breaks `cloneElement`-using parents (Radix
505    // `asChild` etc.) with "Invalid prop supplied to React.Fragment". The
506    // wrap script's answer is to tuck the markers INSIDE the outer wrapper
507    // <div>, which IS the single JSX-slot child.
508    const tsx = `export default function App() {
509  return (
510    <main>
511      <section className="frag-target">
512        <h1>Hi</h1>
513      </section>
514    </main>
515  );
516}`;
517    writeFileSync(join(tmp, 'App.tsx'), tsx);
518
519    execSync(
520      `node skill/scripts/live-wrap.mjs --id frag1 --count 3 --classes "frag-target" --tag "section" --file "${join(tmp, 'App.tsx')}"`,
521      { cwd: process.cwd(), encoding: 'utf-8' }
522    );
523
524    const modified = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
525    // No JSX Fragment wrappers (those break asChild/cloneElement parents).
526    assert.ok(!modified.includes('<>'),  'no Fragment opener emitted');
527    assert.ok(!modified.includes('</>'), 'no Fragment closer emitted');
528
529    // The outer wrapper <div data-impeccable-variants="..."> appears BEFORE
530    // both marker comments — markers are tucked inside.
531    const wrapperIdx = modified.indexOf('data-impeccable-variants="frag1"');
532    const startMarkerIdx = modified.indexOf('impeccable-variants-start frag1');
533    const endMarkerIdx = modified.indexOf('impeccable-variants-end frag1');
534    assert.ok(wrapperIdx !== -1 && startMarkerIdx !== -1 && endMarkerIdx !== -1, 'all markers present');
535    assert.ok(wrapperIdx < startMarkerIdx, 'wrapper opens before start-marker comment');
536    assert.ok(endMarkerIdx > startMarkerIdx, 'end marker follows start marker');
537  });
538
539  it('HTML wrapper keeps marker comments OUTSIDE the wrapper <div> (existing layout)', () => {
540    const html = '<main>\n  <section class="html-frag">Hi</section>\n</main>';
541    writeFileSync(join(tmp, 'page.html'), html);
542
543    execSync(
544      `node skill/scripts/live-wrap.mjs --id htmlFrag --count 3 --classes "html-frag" --tag "section" --file "${join(tmp, 'page.html')}"`,
545      { cwd: process.cwd(), encoding: 'utf-8' }
546    );
547
548    const modified = readFileSync(join(tmp, 'page.html'), 'utf-8');
549    const wrapperIdx = modified.indexOf('data-impeccable-variants="htmlFrag"');
550    const startMarkerIdx = modified.indexOf('impeccable-variants-start htmlFrag');
551    assert.ok(startMarkerIdx < wrapperIdx, 'HTML start marker precedes wrapper div');
552  });
553
554  it('disambiguates repeated JSX siblings via --text and lands on the correct branch', () => {
555    // Three <aside className="card"> elements with identical classes/tag —
556    // the user picked the SECOND one. Without --text, first-match wraps the
557    // first. With --text matching the picked element's textContent, wrap
558    // narrows to the right branch.
559    const tsx = `export default function Page() {
560  return (
561    <main>
562      <aside className="card">
563        <h2>Alpha card</h2>
564        <p>First in the list.</p>
565      </aside>
566      <aside className="card">
567        <h2>Beta card</h2>
568        <p>Second in the list.</p>
569      </aside>
570      <aside className="card">
571        <h2>Gamma card</h2>
572        <p>Third in the list.</p>
573      </aside>
574    </main>
575  );
576}`;
577    writeFileSync(join(tmp, 'Page.tsx'), tsx);
578
579    execSync(
580      `node skill/scripts/live-wrap.mjs --id repeat1 --count 3 --classes "card" --tag "aside" --text "Beta card Second in the list." --file "${join(tmp, 'Page.tsx')}"`,
581      { cwd: process.cwd(), encoding: 'utf-8' }
582    );
583
584    const modified = readFileSync(join(tmp, 'Page.tsx'), 'utf-8');
585    const originalMatch = modified.match(/data-impeccable-variant="original"[\s\S]*?<\/div>/);
586    assert.ok(originalMatch, 'original wrapper present');
587    const inside = originalMatch[0];
588    assert.ok(inside.includes('Beta card'), 'wrapped the Beta card (the picked one)');
589    assert.ok(!inside.includes('Alpha card'), 'did not wrap Alpha');
590    assert.ok(!inside.includes('Gamma card'), 'did not wrap Gamma');
591  });
592
593  it('disambiguates when the picked element has multiple text-node children (textContent has no inter-element whitespace)', () => {
594    // Real-world regression caught while driving a live loop in the browser.
595    // textContent concatenates child text without inserting whitespace, so
596    // an <aside><h1>Hero Two</h1><p>Second card body copy.</p></aside> reads
597    // as "Hero TwoSecond card body copy." — but the source has whitespace
598    // between </h1> and <p>. A single-space normalization on both sides
599    // misses the join boundary; a no-whitespace normalization catches it.
600    const tsx = `export default function Page() {
601  return (
602    <main>
603      <aside className="card">
604        <h1 className="hero-title">Hero One</h1>
605        <p className="hero-hook">First card body copy.</p>
606      </aside>
607      <aside className="card">
608        <h1 className="hero-title">Hero Two</h1>
609        <p className="hero-hook">Second card body copy.</p>
610      </aside>
611      <aside className="card">
612        <h1 className="hero-title">Hero Three</h1>
613        <p className="hero-hook">Third card body copy.</p>
614      </aside>
615    </main>
616  );
617}`;
618    writeFileSync(join(tmp, 'Page.tsx'), tsx);
619
620    // Note: --text is the textContent the BROWSER produced — no space between
621    // "Two" and "Second" because textContent has no inter-element whitespace.
622    execSync(
623      `node skill/scripts/live-wrap.mjs --id concat1 --count 3 --classes "card" --tag "aside" --text "Hero TwoSecond card body copy." --file "${join(tmp, 'Page.tsx')}"`,
624      { cwd: process.cwd(), encoding: 'utf-8' }
625    );
626
627    const modified = readFileSync(join(tmp, 'Page.tsx'), 'utf-8');
628    const originalMatch = modified.match(/data-impeccable-variant="original"[\s\S]*?<\/div>/);
629    assert.ok(originalMatch, 'original wrapper present');
630    const inside = originalMatch[0];
631    assert.ok(inside.includes('Hero Two'), 'wrapped Hero Two (the picked card)');
632    assert.ok(!inside.includes('Hero One'), 'did not wrap Hero One');
633    assert.ok(!inside.includes('Hero Three'), 'did not wrap Hero Three');
634  });
635
636  it('short --text falls back to first-match instead of erroneously firing element_ambiguous', () => {
637    // Cursor Bugbot regression: filterByText returned `candidates.slice()`
638    // (all candidates) when the trimmed snippet was shorter than 8 chars.
639    // The caller treats `filtered.length > 1` as ambiguous — so a short
640    // textContent on a page with multiple matching siblings produced a
641    // spurious `element_ambiguous` error instead of just landing on the
642    // first match (the documented short-text fallback).
643    const tsx = `export default function Page() {
644  return (
645    <main>
646      <aside className="card"><h1 className="hero-title">Hi</h1></aside>
647      <aside className="card"><h1 className="hero-title">Hi</h1></aside>
648    </main>
649  );
650}`;
651    writeFileSync(join(tmp, 'Short.tsx'), tsx);
652
653    // Picked element's textContent is 'Hi' — only 2 chars. With multiple
654    // matching siblings the prior bug fired element_ambiguous; the fix
655    // makes wrap silently land on the first match (existing behavior
656    // documented in filterByText's JSDoc).
657    execSync(
658      `node skill/scripts/live-wrap.mjs --id short1 --count 3 --classes "card" --tag "aside" --text "Hi" --file "${join(tmp, 'Short.tsx')}"`,
659      { cwd: process.cwd(), encoding: 'utf-8' }
660    );
661
662    const modified = readFileSync(join(tmp, 'Short.tsx'), 'utf-8');
663    assert.ok(modified.includes('data-impeccable-variants="short1"'),
664      'short --text should still wrap (fallback to first-match), not fail with element_ambiguous');
665  });
666
667  it('returns endLine that includes the multi-line original content offset', () => {
668    // Cursor Bugbot regression: the `endLine` field was computed as
669    // `startLine + wrapperLines.length`, but `wrapperLines` is an array
670    // where one element (originalIndented) is a `\n`-joined multi-line
671    // string. For multi-line picked elements, the actual wrapper region
672    // in the file spans (wrapperLines.length + originalLines.length - 1)
673    // rows. Reporting too-small endLine misled agents writing variants
674    // about the wrapper boundary.
675    const html = `<main>
676  <section class="multiline-target">
677    <h1>Multi</h1>
678    <p>Line</p>
679    <span>Element</span>
680  </section>
681</main>`;
682    writeFileSync(join(tmp, 'multi.html'), html);
683
684    const result = JSON.parse(execSync(
685      `node skill/scripts/live-wrap.mjs --id ml1 --count 3 --classes "multiline-target" --tag "section" --file "${join(tmp, 'multi.html')}"`,
686      { cwd: process.cwd(), encoding: 'utf-8' }
687    ));
688
689    const modified = readFileSync(join(tmp, 'multi.html'), 'utf-8');
690    const lines = modified.split('\n');
691    // endLine is 1-indexed; lines[endLine - 1] should be the wrapper's last
692    // line (the impeccable-variants-end marker for HTML).
693    assert.match(lines[result.endLine - 1], /impeccable-variants-end ml1/,
694      `endLine ${result.endLine} should point at the variants-end marker line. Got: ${JSON.stringify(lines[result.endLine - 1])}`);
695    // And the line after the reported endLine should be `</main>` — proving
696    // the entire wrapper was accounted for (no rows missing).
697    assert.match(lines[result.endLine], /<\/main>/,
698      `line after endLine should be </main>; got: ${JSON.stringify(lines[result.endLine])}`);
699  });
700
701  it('falls back to first-match when --text is not literally present in source (e.g. {title})', () => {
702    // textContent the browser sends is the rendered text, but the source uses
703    // a JSX expression. No candidate's source body contains the literal
704    // textContent — wrap should keep the first-match behavior rather than
705    // refusing, because failing here would be more annoying than wrong.
706    const tsx = `export default function Cards({ items }) {
707  return (
708    <main>
709      {items.map(item => (
710        <aside key={item.id} className="card">
711          <h2>{item.title}</h2>
712        </aside>
713      ))}
714    </main>
715  );
716}`;
717    writeFileSync(join(tmp, 'Cards.tsx'), tsx);
718
719    // Run with --text that won't show up in source verbatim.
720    execSync(
721      `node skill/scripts/live-wrap.mjs --id dyn1 --count 3 --classes "card" --tag "aside" --text "Beta card body text" --file "${join(tmp, 'Cards.tsx')}"`,
722      { cwd: process.cwd(), encoding: 'utf-8' }
723    );
724
725    const modified = readFileSync(join(tmp, 'Cards.tsx'), 'utf-8');
726    assert.ok(modified.includes('data-impeccable-variants="dyn1"'), 'wrapped (first-match fallback)');
727  });
728
729  it('errors with element_ambiguous when --text matches multiple identical branches', () => {
730    // Two <aside className="card"> with truly identical body text. --text
731    // can't pick a winner — wrap should refuse rather than silently land.
732    const tsx = `export default function Page() {
733  return (
734    <main>
735      <aside className="card">
736        <h2>Same headline</h2>
737        <p>Identical body copy.</p>
738      </aside>
739      <aside className="card">
740        <h2>Same headline</h2>
741        <p>Identical body copy.</p>
742      </aside>
743    </main>
744  );
745}`;
746    writeFileSync(join(tmp, 'Dup.tsx'), tsx);
747
748    let errPayload;
749    try {
750      execSync(
751        `node skill/scripts/live-wrap.mjs --id dup1 --count 3 --classes "card" --tag "aside" --text "Same headline Identical body copy." --file "${join(tmp, 'Dup.tsx')}"`,
752        { cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' }
753      );
754      assert.fail('Should have exited with error');
755    } catch (err) {
756      assert.ok(err.status !== 0, 'non-zero exit');
757      errPayload = JSON.parse(err.stderr.toString().trim());
758    }
759    assert.equal(errPayload.error, 'element_ambiguous');
760    assert.equal(errPayload.fallback, 'agent-driven');
761    assert.ok(Array.isArray(errPayload.candidates) && errPayload.candidates.length === 2,
762      'two candidate locations reported');
763  });
764
765  it('respects --tag to reject matches inside the wrong element type', () => {
766    // Two elements, both containing the class. The <div> comes first in source
767    // order; a tag-agnostic search would wrap it. With --tag section, the
768    // <section> is the only valid target.
769    const html = `<main>
770  <div class="ambiguous-name">Decoy div</div>
771  <section class="ambiguous-name">Target section</section>
772</main>`;
773    writeFileSync(join(tmp, 'index.html'), html);
774
775    execSync(
776      `node skill/scripts/live-wrap.mjs --id tagFilter --count 3 --classes "ambiguous-name" --tag "section" --file "${join(tmp, 'index.html')}"`,
777      { cwd: process.cwd(), encoding: 'utf-8' }
778    );
779
780    const modified = readFileSync(join(tmp, 'index.html'), 'utf-8');
781    const originalMatch = modified.match(/data-impeccable-variant="original"[^>]*>([\s\S]*?)\s*<\/div>/);
782    assert.ok(originalMatch, 'original variant wrapper exists');
783    const inside = originalMatch[1];
784    assert.ok(inside.includes('<section'), 'section was wrapped');
785    assert.ok(inside.includes('Target section'), 'target content is inside wrapper');
786    assert.ok(!inside.includes('Decoy div'), 'div decoy was not wrapped');
787  });
788});
789
790describe('findClosingLine — edge cases', () => {
791  it('recognises an opener line where the tag sits at end-of-line (multi-line JSX)', () => {
792    const lines = [
793      '<section',
794      '  className="hero"',
795      '>',
796      '  <h1>Hi</h1>',
797      '</section>',
798    ];
799    // findClosingLine should treat line 0 as a valid opener and span to line 4.
800    assert.equal(findClosingLine(lines, 0), 4);
801  });
802});