agent.mjs

  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, '&amp;').replace(/'/g, '&apos;');
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, '&#123;').replace(/\}/g, '&#125;');
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}