1/**
2 * Agent module for the live-mode E2E test suite.
3 *
4 * Two layers:
5 *
6 * 1. `runAgentLoop(opts)` — the deterministic wrapper around the live-mode
7 * poll/wrap/write/accept protocol. This is identical for fake and real
8 * agents; only the variant-content production step differs.
9 *
10 * 2. `createFakeAgent()` — produces canned variants in the EXACT format
11 * `skill/reference/live.md` describes: a colocated
12 * `<style data-impeccable-css="ID">` block with `@scope ([data-impeccable-variant="N"])`
13 * rules, a `data-impeccable-params` JSON manifest covering range + steps + toggle
14 * kinds across the variant set, single top-level element per variant matching
15 * the original tag.
16 *
17 * A future LLM-backed agent slots in by implementing the same VariantAgent
18 * interface (one method, `generateVariants(event, context)`), so the loop and
19 * harness stay unchanged.
20 */
21
22import fs from 'node:fs/promises';
23import path from 'node:path';
24import { execFile } from 'node:child_process';
25import { promisify } from 'node:util';
26
27const execFileP = promisify(execFile);
28
29// ---------------------------------------------------------------------------
30// Variant-output schema
31// ---------------------------------------------------------------------------
32
33/**
34 * @typedef {Object} ParamSpec
35 * @property {string} id
36 * @property {'range' | 'steps' | 'toggle'} kind
37 * @property {string} label
38 * @property {*} default
39 * @property {number=} min
40 * @property {number=} max
41 * @property {number=} step
42 * @property {Array<{value: string, label: string}>=} options
43 *
44 * @typedef {Object} VariantSpec
45 * @property {string} innerHtml Single top-level element matching the
46 * original's tag (e.g. '<h1 ...>...</h1>').
47 * @property {ParamSpec[]=} params Optional 0-4 param manifest.
48 *
49 * @typedef {Object} GenerateOutput
50 * @property {string} scopedCss Contents of the <style data-impeccable-css>
51 * block — `@scope` rules per variant.
52 * @property {VariantSpec[]} variants
53 *
54 * @typedef {Object} VariantAgent
55 * @property {(event: object, context: object) => Promise<GenerateOutput>} generateVariants
56 */
57
58// ---------------------------------------------------------------------------
59// Fake agent — canned, format-faithful variants
60// ---------------------------------------------------------------------------
61
62/**
63 * Build a fake agent that produces deterministic variants for an `<h1 class="hero-title">`
64 * target. The exact CSS values are chosen so the test can later assert them
65 * via `getComputedStyle` — variant 1 → red, variant 2 → bold, variant 3 → uppercase.
66 *
67 * The output mirrors a real agent's write-back faithfully:
68 * - <style data-impeccable-css="ID"> with @scope rules per variant
69 * - data-impeccable-params manifest with range + steps + toggle kinds
70 * - first variant visible (no display:none), rest hidden by the agent caller
71 * - inner content = single <h1> per variant
72 */
73export function createFakeAgent() {
74 return {
75 /** @type {VariantAgent['generateVariants']} */
76 async generateVariants(event, context = {}) {
77 const text = extractText(event.element?.outerHTML) || 'Title';
78 const cls = 'hero-title';
79 const useAstroGlobalCss = context.wrapInfo?.styleMode === 'astro-global-prefixed';
80
81 // Variant 1 — red color, with a `range` param tuning hue lightness.
82 const variant1 = {
83 innerHtml: `<h1 class="${cls}">${text}</h1>`,
84 params: [
85 {
86 id: 'lightness',
87 kind: 'range',
88 min: 0.3,
89 max: 0.7,
90 step: 0.05,
91 default: 0.5,
92 label: 'Lightness',
93 },
94 ],
95 };
96
97 // Variant 2 — bold weight, with a `steps` param for serif/sans/mono.
98 const variant2 = {
99 innerHtml: `<h1 class="${cls}">${text}</h1>`,
100 params: [
101 {
102 id: 'face',
103 kind: 'steps',
104 default: 'sans',
105 label: 'Face',
106 options: [
107 { value: 'sans', label: 'Sans' },
108 { value: 'serif', label: 'Serif' },
109 { value: 'mono', label: 'Mono' },
110 ],
111 },
112 ],
113 };
114
115 // Variant 3 — uppercase, with a `toggle` param for italic.
116 const variant3 = {
117 innerHtml: `<h1 class="${cls}">${text}</h1>`,
118 params: [
119 {
120 id: 'italic',
121 kind: 'toggle',
122 default: false,
123 label: 'Italic',
124 },
125 ],
126 };
127
128 // Scoped CSS for most frameworks. Astro component styles are transformed
129 // and scoped by the compiler, so live preview CSS must use a global style
130 // tag plus explicit variant prefixes instead of raw @scope rules.
131 const scopedCss = useAstroGlobalCss
132 ? [
133 '[data-impeccable-variant="1"] > h1 {',
134 ' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
135 '}',
136 '[data-impeccable-variant="2"] > h1 { font-weight: 900; }',
137 '[data-impeccable-variant="2"][data-p-face="serif"] > h1 { font-family: ui-serif, serif; }',
138 '[data-impeccable-variant="2"][data-p-face="mono"] > h1 { font-family: ui-monospace, monospace; }',
139 '[data-impeccable-variant="3"] > h1 { text-transform: uppercase; letter-spacing: 0.04em; }',
140 '[data-impeccable-variant="3"][data-p-italic] > h1 { font-style: italic; }',
141 ].join('\n')
142 : [
143 '@scope ([data-impeccable-variant="1"]) {',
144 ' :scope > h1 {',
145 ' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
146 ' }',
147 '}',
148 '@scope ([data-impeccable-variant="2"]) {',
149 ' :scope > h1 { font-weight: 900; }',
150 ' :scope[data-p-face="serif"] > h1 { font-family: ui-serif, serif; }',
151 ' :scope[data-p-face="mono"] > h1 { font-family: ui-monospace, monospace; }',
152 '}',
153 '@scope ([data-impeccable-variant="3"]) {',
154 ' :scope > h1 { text-transform: uppercase; letter-spacing: 0.04em; }',
155 ' :scope[data-p-italic] > h1 { font-style: italic; }',
156 '}',
157 ].join('\n');
158
159 return {
160 scopedCss,
161 variants: [variant1, variant2, variant3],
162 };
163 },
164 };
165}
166
167// ---------------------------------------------------------------------------
168// Helpers
169// ---------------------------------------------------------------------------
170
171function extractText(outerHTML) {
172 if (!outerHTML) return null;
173 const m = outerHTML.match(/>([^<]+)</);
174 return m ? m[1].trim() : null;
175}
176
177function attrEscape(str, { svelte = false } = {}) {
178 let s = String(str).replace(/&/g, '&').replace(/'/g, ''');
179 if (svelte) {
180 // Svelte parses `{` in attribute values as expression starters even
181 // inside quoted strings — see https://svelte.dev/e/expected_token .
182 // Escape with HTML numeric entities so the literal characters land in
183 // the rendered DOM attribute.
184 s = s.replace(/\{/g, '{').replace(/\}/g, '}');
185 }
186 return s;
187}
188
189/**
190 * Translate an HTML snippet to JSX. Currently: class= → className=, optionally
191 * preserves whitespace + tags. The fake agent writes innerHtml in HTML form;
192 * the orchestrator translates per the target file's syntax.
193 */
194function htmlToJsx(html) {
195 return html.replace(/\bclass=/g, 'className=');
196}
197
198/**
199 * Render the variants block in either HTML or JSX, depending on commentSyntax.
200 * In JSX:
201 * - comments use {/* ... */} (already what commentSyntax.open is)
202 * - <style>{`@scope ... { ... }`}</style> wraps CSS in a template literal so JSX
203 * doesn't choke on the {} in CSS
204 * - non-default visible variants use style={{display: 'none'}}
205 * - inner element class= becomes className=
206 * - data-impeccable-params stays a single-quoted JSON string (JSX-legal)
207 */
208function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file, styleMode }) {
209 const isJsx = commentSyntax.open === '{/*';
210 const isSvelte = !!file && file.endsWith('.svelte');
211 const isAstroGlobalCss = styleMode === 'astro-global-prefixed';
212
213 const styleLines = isJsx
214 ? [
215 indent + ' <style data-impeccable-css="' + sessionId + '">{`',
216 ...output.scopedCss.split('\n').map((l) => indent + ' ' + l),
217 indent + ' `}</style>',
218 ]
219 : [
220 indent + ' <style' + (isAstroGlobalCss ? ' is:inline' : '') + ' data-impeccable-css="' + sessionId + '">',
221 ...output.scopedCss.split('\n').map((l) => indent + ' ' + l),
222 indent + ' </style>',
223 ];
224
225 const variantBlocks = output.variants.map((v, i) => {
226 const idx = i + 1;
227 const paramsAttr = v.params && v.params.length
228 ? " data-impeccable-params='" + attrEscape(JSON.stringify(v.params), { svelte: isSvelte }) + "'"
229 : '';
230 let styleAttr = '';
231 if (i !== 0) styleAttr = isJsx ? " style={{display: 'none'}}" : ' style="display: none"';
232 const inner = isJsx ? htmlToJsx(v.innerHtml) : v.innerHtml;
233 return [
234 indent + ' ' + commentSyntax.open + ' Variant ' + idx + ' ' + commentSyntax.close,
235 indent + ' <div data-impeccable-variant="' + idx + '"' + styleAttr + paramsAttr + '>',
236 indent + ' ' + inner,
237 indent + ' </div>',
238 ].join('\n');
239 });
240
241 return [...styleLines, ...variantBlocks].join('\n');
242}
243
244/**
245 * Read the wrapped file, find the "insert below this line" marker, splice in
246 * the rendered variants block, write back.
247 */
248async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
249 const filePath = path.join(tmp, wrapInfo.file);
250 const src = await fs.readFile(filePath, 'utf-8');
251 const lines = src.split('\n');
252
253 // Find the "Variants: insert below this line" comment line — definitive
254 // marker, robust to any indentation off-by-one. Matches in any comment
255 // style (HTML / JSX / Astro).
256 const markerIdx = lines.findIndex((l) =>
257 l.includes('Variants: insert below this line'),
258 );
259 if (markerIdx === -1) {
260 throw new Error('insert marker not found in ' + wrapInfo.file);
261 }
262
263 const indent = (lines[markerIdx].match(/^\s*/) || [''])[0];
264 // Indent INSIDE the wrapper is one level shallower (the marker is indented
265 // 2 spaces relative to the wrapper opening). Remove the 2-space comment
266 // indent to get the wrapper indent.
267 const wrapperIndent = indent.replace(/ $/, '');
268
269 const block = renderVariantsBlock({
270 sessionId,
271 indent: wrapperIndent,
272 output,
273 commentSyntax: wrapInfo.commentSyntax,
274 file: wrapInfo.file,
275 styleMode: wrapInfo.styleMode,
276 });
277
278 const next = [
279 ...lines.slice(0, markerIdx + 1),
280 block,
281 ...lines.slice(markerIdx + 1),
282 ];
283 await fs.writeFile(filePath, next.join('\n'), 'utf-8');
284}
285
286// ---------------------------------------------------------------------------
287// Poll loop — the "agent" runs this until aborted
288// ---------------------------------------------------------------------------
289
290/**
291 * @param {object} opts
292 * @param {string} opts.tmp Project tmp dir (cwd for live-* scripts).
293 * @param {string} opts.scriptsDir Path to the impeccable scripts dir.
294 * @param {number} opts.port live-server port.
295 * @param {string} opts.token live-server token.
296 * @param {VariantAgent} opts.agent
297 * @param {AbortSignal} opts.signal
298 * @param {(msg: string) => void} [opts.log]
299 * @param {object} [opts.wrapTarget] Default target for live-wrap when an
300 * element comes from the picker without an
301 * id we can resolve. e.g. {classes:'hero-title', tag:'h1'}.
302 */
303export async function runAgentLoop({
304 tmp,
305 scriptsDir,
306 port,
307 token,
308 agent,
309 signal,
310 log = () => {},
311 wrapTarget = { classes: 'hero-title', tag: 'h1' },
312}) {
313 const base = `http://127.0.0.1:${port}`;
314
315 while (!signal.aborted) {
316 let event;
317 try {
318 const res = await fetch(`${base}/poll?token=${token}&timeout=5000`, { signal });
319 event = await res.json();
320 } catch (err) {
321 if (signal.aborted) return;
322 log('poll error: ' + err.message);
323 await new Promise((r) => setTimeout(r, 200));
324 continue;
325 }
326
327 if (event.type === 'timeout') continue;
328 if (event.type === 'exit') return;
329 if (event.type === 'prefetch') continue;
330 if (event.type === 'connected') continue;
331
332 if (event.type === 'generate') {
333 log(`generate id=${event.id} action=${event.action} count=${event.count}`);
334 try {
335 // 1. Wrap the original element in the variant scaffold (deterministic CLI)
336 // wrapTarget can be a static {classes, tag, elementId} (test fixtures
337 // know what they pick) or a function (event) => target (real-use
338 // sessions: the agent must derive the selector from the picked
339 // element on the fly).
340 const target = typeof wrapTarget === 'function' ? wrapTarget(event) : wrapTarget;
341 // Pull textContent from the picker event so wrap can disambiguate
342 // when sibling elements share classes/tag (issue #114). Fixtures can
343 // still override by including `text` in their wrapTarget.
344 const text = target.text ?? (event.element?.textContent || '').trim();
345 const wrapInfo = await runWrap({
346 tmp,
347 scriptsDir,
348 id: event.id,
349 count: event.count,
350 ...target,
351 text,
352 });
353 log(`wrapped: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`);
354
355 // 2. Agent generates variant content (LLM-pluggable seam)
356 const output = await agent.generateVariants(event, { wrapTarget, wrapInfo });
357 if (output.variants.length !== event.count) {
358 log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
359 }
360
361 // 3. Splice variants block into the wrapper (deterministic fs)
362 await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
363 if (process.env.IMPECCABLE_E2E_DEBUG) {
364 const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
365 log(`--- post-splice (variants written) ---\n${post}`);
366 }
367
368 // 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING)
369 await fetch(`${base}/poll`, {
370 method: 'POST',
371 headers: { 'Content-Type': 'application/json' },
372 body: JSON.stringify({ token, type: 'done', id: event.id }),
373 signal,
374 });
375 } catch (err) {
376 if (signal.aborted) return;
377 log('generate failed: ' + err.message);
378 await fetch(`${base}/poll`, {
379 method: 'POST',
380 headers: { 'Content-Type': 'application/json' },
381 body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }),
382 signal,
383 }).catch(() => {});
384 }
385 continue;
386 }
387
388 if (event.type === 'accept') {
389 log(`accept id=${event.id} variantId=${event.variantId}`);
390 try {
391 const acceptResult = await runAccept({
392 tmp,
393 scriptsDir,
394 id: event.id,
395 variant: event.variantId,
396 paramValues: event.paramValues,
397 });
398
399 // Carbonize cleanup — required after accept per the live skill spec.
400 // For the fake agent, we perform a faithful but minimal cleanup:
401 // delete the carbonize block (markers + dead variants + inline <style>
402 // + param-values comment) and unwrap the temporary variant div around
403 // the accepted content. A real LLM agent would additionally migrate
404 // the @scope rules into the project's stylesheet — out of scope for
405 // a deterministic test.
406 if (acceptResult.handled === true && acceptResult.carbonize === true && acceptResult.file) {
407 if (process.env.IMPECCABLE_E2E_DEBUG) {
408 const post = await fs.readFile(path.join(tmp, acceptResult.file), 'utf-8');
409 log(`--- post-accept (pre-carbonize) ---\n${post}`);
410 }
411 await runCarbonizeCleanup({ tmp, file: acceptResult.file, sessionId: event.id, variant: event.variantId });
412 log(`carbonize cleanup done on ${acceptResult.file}`);
413 }
414
415 await fetch(`${base}/poll`, {
416 method: 'POST',
417 headers: { 'Content-Type': 'application/json' },
418 body: JSON.stringify({ token, type: 'accept', id: event.id, data: { _acceptResult: acceptResult } }),
419 signal,
420 });
421 } catch (err) {
422 if (signal.aborted) return;
423 log('accept failed: ' + err.message);
424 }
425 continue;
426 }
427
428 if (event.type === 'discard') {
429 log(`discard id=${event.id}`);
430 try {
431 const discardResult = await runAccept({ tmp, scriptsDir, id: event.id, discard: true });
432 await fetch(`${base}/poll`, {
433 method: 'POST',
434 headers: { 'Content-Type': 'application/json' },
435 body: JSON.stringify({ token, type: 'discard', id: event.id, data: { _acceptResult: discardResult } }),
436 signal,
437 });
438 } catch (err) {
439 if (signal.aborted) return;
440 log('discard failed: ' + err.message);
441 }
442 continue;
443 }
444
445 log(`unhandled event: ${event.type}`);
446 }
447}
448
449async function runWrap({ tmp, scriptsDir, id, count, classes, tag, elementId, text }) {
450 const args = [path.join(scriptsDir, 'live-wrap.mjs'), '--id', id, '--count', String(count)];
451 if (elementId) args.push('--element-id', elementId);
452 if (classes) args.push('--classes', classes);
453 if (tag) args.push('--tag', tag);
454 if (text) args.push('--text', text);
455 const { stdout } = await execFileP(process.execPath, args, { cwd: tmp });
456 const last = stdout.trim().split('\n').filter(Boolean).pop();
457 return JSON.parse(last);
458}
459
460/**
461 * Apply the post-accept carbonize cleanup to the given file. Mirrors the
462 * five-step rewrite the live skill expects of the agent:
463 *
464 * 1. Locate the carbonize block (bracketed by `impeccable-carbonize-start`
465 * and `impeccable-carbonize-end`).
466 * 2. Step 2 ("move CSS into the project stylesheet") is skipped — that
467 * requires per-project judgment about which file owns these styles.
468 * The fake agent leaves CSS migration to the LLM-backed agent.
469 * 3-5. Strip the carbonize block entirely AND unwrap the temporary
470 * `<div data-impeccable-variant="N" style="display: contents"|...>` wrapper
471 * that holds the accepted content. The accepted inner element survives.
472 */
473async function runCarbonizeCleanup({ tmp, file, sessionId /* , variant */ }) {
474 const filePath = path.join(tmp, file);
475 let body = await fs.readFile(filePath, 'utf-8');
476
477 // 1. Strip the carbonize block. We match either comment style so this
478 // works for both HTML and JSX targets.
479 const startRe = new RegExp('[ \\t]*(?:<!--|\\{/\\*)\\s*impeccable-carbonize-start\\s+' + sessionId + '\\s*(?:-->|\\*/\\})\\n');
480 const endRe = new RegExp('[ \\t]*(?:<!--|\\{/\\*)\\s*impeccable-carbonize-end\\s+' + sessionId + '\\s*(?:-->|\\*/\\})\\n?');
481 const startMatch = body.match(startRe);
482 const endMatch = body.match(endRe);
483 if (startMatch && endMatch && startMatch.index < endMatch.index) {
484 const startIdx = startMatch.index;
485 const endIdx = endMatch.index + endMatch[0].length;
486 body = body.slice(0, startIdx) + body.slice(endIdx);
487 }
488
489 // 2. Unwrap the temporary `<div data-impeccable-variant="N" ...>` placed
490 // around the accepted content. live-accept emits this wrapper with
491 // `style="display: contents"` so it doesn't affect layout. We strip the
492 // wrapper open/close lines and keep what's between.
493 // Match the opening div (any single line) followed by inner content
494 // followed by `</div>`, where the open carries data-impeccable-variant
495 // and is NOT inside a data-impeccable-variants wrapper (the variants
496 // wrapper has the trailing `s`).
497 body = body.replace(
498 /^([ \t]*)<div\b[^>]*\bdata-impeccable-variant="[^"]+"[^>]*>\n([\s\S]*?)\n[ \t]*<\/div>\n/m,
499 (match, indent, inner) => {
500 // Re-indent inner content to the wrapper's indent level.
501 const innerLines = inner.split('\n');
502 const innerIndent = (innerLines[0].match(/^\s*/) || [''])[0];
503 const dedented = innerLines.map((l) => {
504 if (l.startsWith(innerIndent)) return indent + l.slice(innerIndent.length);
505 return l;
506 }).join('\n');
507 return dedented + '\n';
508 },
509 );
510
511 await fs.writeFile(filePath, body, 'utf-8');
512}
513
514async function runAccept({ tmp, scriptsDir, id, variant, discard, paramValues }) {
515 const args = [path.join(scriptsDir, 'live-accept.mjs'), '--id', id];
516 if (discard) args.push('--discard');
517 else args.push('--variant', String(variant));
518 if (paramValues) args.push('--param-values', JSON.stringify(paramValues));
519 const { stdout } = await execFileP(process.execPath, args, { cwd: tmp });
520 const last = stdout.trim().split('\n').filter(Boolean).pop();
521 return JSON.parse(last);
522}