live-browser-regression.test.mjs

 1/**
 2 * Static-source regression guards for live-browser.js.
 3 *
 4 * `skill/scripts/live-browser.js` is a self-contained
 5 * IIFE served directly to user pages by live-server.mjs (no bundle step,
 6 * no module exports). That makes its internal helpers untestable via
 7 * normal import — but a few behaviors have failed in real-world live
 8 * sessions in ways that are easy to express as "this exact code shape
 9 * MUST NOT come back." This file pins those down.
10 *
11 * Add a guard whenever a bug we fix has a one-line "anti-pattern" cause
12 * that's easy to reintroduce on an unrelated edit.
13 */
14
15import { describe, it } from 'node:test';
16import assert from 'node:assert/strict';
17import fs from 'node:fs';
18import path from 'node:path';
19import { fileURLToPath } from 'node:url';
20
21const __dirname = path.dirname(fileURLToPath(import.meta.url));
22const LIVE_BROWSER = path.resolve(
23  __dirname,
24  '..',
25  'skill/scripts/live-browser.js',
26);
27const SOURCE = fs.readFileSync(LIVE_BROWSER, 'utf-8');
28
29describe('live-browser.js regression guards', () => {
30  it('resolveCanvasBackground does not fall back to `getComputedStyle(...).backgroundColor || ...`', () => {
31    // The browser returns the literal string `"rgba(0, 0, 0, 0)"` for an
32    // unset body/html background. That string is non-empty and truthy, so a
33    // `||` chain short-circuits to transparent-black, which modern-screenshot
34    // hands to its WebGL shader as the canvas color and the screenshot
35    // overlay flashes solid black during loading on any page that doesn't
36    // explicitly set its own background. Forbid the pattern outright; the
37    // correct fallback is a literal `'#ffffff'` (the browser's default
38    // canvas color).
39    const buggy =
40      /getComputedStyle\(document\.(?:body|documentElement)\)\.backgroundColor\s*\|\|/;
41    assert.ok(
42      !buggy.test(SOURCE),
43      'live-browser.js must not chain `getComputedStyle(...).backgroundColor || ...` — that returns transparent-black for default-bg pages and renders the screenshot overlay as solid black during loading. Use a literal fallback (`#ffffff`) instead.',
44    );
45  });
46
47  it('detectPageTheme honors alpha when reading body / html backgroundColor', () => {
48    // Equivalent trap: `rgba(0, 0, 0, 0)` parsed naively as `(0,0,0)` makes
49    // a perfectly white default page register as "dark," which flips the
50    // chrome to the wrong palette. The fix introduced an alpha guard
51    // (function readOpaque) — keep that signature in source.
52    assert.match(
53      SOURCE,
54      /function detectPageTheme\b[\s\S]{0,1500}?function readOpaque\b/,
55      'detectPageTheme must keep its readOpaque helper that filters out fully-transparent backgrounds before computing luminance',
56    );
57  });
58
59  it('handleServerLost preserves the current recoverable phase', () => {
60    assert.doesNotMatch(
61      SOURCE,
62      /state\s*=\s*currentSessionId\s*\?\s*['"]GENERATING['"]\s*:\s*['"]IDLE['"]/,
63      'event=live_browser.server_lost_phase actor=browser operation=sse_disconnect risk=cycling_or_saving_session_saved_as_generating expected=preserve current phase actual=forced generating',
64    );
65    assert.match(
66      SOURCE,
67      /function handleServerLost\(\)[\s\S]{0,300}?const recoveryState = currentSessionId \? state : 'IDLE';[\s\S]{0,1200}?state = recoveryState;[\s\S]{0,120}?if \(currentSessionId\) saveSession\(\);/,
68      'server-lost cleanup should keep the current session phase in local recovery state instead of rewriting it to GENERATING',
69    );
70  });
71
72  it('source reinjection preserves the visible variant after cycling', () => {
73    assert.doesNotMatch(
74      SOURCE,
75      /Replace the live element[\s\S]{0,900}?visibleVariant\s*=\s*1;\s*showVariantInDOM\(sessionId,\s*1\);/,
76      'event=live_browser.visible_variant_reset actor=browser operation=hmr_source_reinject risk=late_hmr_accepts_variant_1_after_user_cycles expected=preserve visible variant actual=reset_to_first',
77    );
78    assert.match(
79      SOURCE,
80      /previousVisibleVariant[\s\S]{0,900}?savedVisibleVariant[\s\S]{0,500}?showVariantInDOM\(sessionId, visibleVariant\);/,
81      'source reinjection should preserve the in-memory or saved visible variant instead of always showing variant 1',
82    );
83  });
84
85  it('handleAccept reads the visible DOM variant before sending accept', () => {
86    assert.match(
87      SOURCE,
88      /function readVisibleVariantFromDOM\(sessionId\)[\s\S]{0,900}?variant\.style\.display === 'none'[\s\S]{0,500}?return idx;/,
89      'live-browser should be able to derive the accepted variant from the currently visible DOM node',
90    );
91    assert.match(
92      SOURCE,
93      /function handleAccept\(\)[\s\S]{0,180}?const domVisibleVariant = readVisibleVariantFromDOM\(currentSessionId\);[\s\S]{0,120}?if \(domVisibleVariant > 0\) visibleVariant = domVisibleVariant;[\s\S]{0,160}?variantId: String\(visibleVariant\)/,
94      'event=live_browser.accept_stale_visible_variant actor=browser operation=accept_after_hmr risk=accept_sends_variant_1_after_user_cycles_to_2 expected=read_dom_visible_variant actual=stale_state_variable',
95    );
96  });
97});