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