# Headless HTML Testing

> Verify interactive standalone HTML artifacts (games, prototypes, demos) by running their script in Node.js with DOM/canvas stubs — no browser required.

- Skill: `wcpaka-lgtm/headless-html-testing` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add wcpaka-lgtm/headless-html-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wcpaka-lgtm/headless-html-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: wcpaka-lgtm (https://skillmd.com/u/wcpaka-lgtm)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/wcpaka-lgtm/headless-html-testing

---


# Headless Testing of Standalone HTML Artifacts

Use when you built a self-contained HTML artifact with real interactive logic
(game, calculator, prototype with a state machine) and must VERIFY it works
before delivering — but no browser automation tool is available.

`node --check` only proves the JS parses. It does not prove the page loads
without crashing, the game loop runs, or scoring works. This skill runs the
artifact's actual script in Node against stubbed DOM/canvas APIs and drives
real scenarios through it. It routinely catches init-order and state-flag bugs
that are invisible to syntax checks.

## Shortcut: test one pure function without the full harness

When the bug lives in a single self-contained pure function (a save migrator, a
scoring formula, a cost calculator) you do NOT need the whole DOM/canvas stub
layer. Extract just that function's source with a regex and rebuild it via
`new Function`, injecting only its few dependencies:

```js
const src = script.match(/function migrateSave\(s\)\{[\s\S]*?return s\}/)[0];
const fn = new Function('RESKEYS','BASEPRICE','SYSTEMS', src + '; return migrateSave;')
             (RESKEYS, BASEPRICE, SYSTEMS);
const out = fn(JSON.parse(JSON.stringify(oldSave))); // feed a synthetic input
assert(out.autoSell.iron === false);
```

This proved a save-migration fix in seconds: build a synthetic OLD save missing
the new fields, run it through the extracted function, assert every previously-
crashing access is now safe. Use the full concatenation harness (below) only
when you need to drive the game loop, rendering, or DOM event handlers.

⚠️ **Regex extraction breaks on nested braces.** A non-greedy
`/function foo\(\)\{[\s\S]*?\}/` stops at the FIRST `}` — so any function whose
body contains a nested block (a `forEach(r=>{...})` callback, an `if(){}`, an
object literal) gets truncated mid-body, and `new Function` throws
`SyntaxError: missing )`. This bit us extracting `routeEst` (body had a
`forEach` callback). Two robust options:

```js
// Option A: balanced-brace scan from the function's opening {
const start = script.indexOf('function routeEst(si)');
let depth = 0, end = start;
for (let i = script.indexOf('{', start); i < script.length; i++) {
  if (script[i] === '{') depth++;
  else if (script[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
}
const fnSrc = script.substring(start, end);

// Option B: anchor the regex on a unique tail that survives nesting,
// e.g. match up to the function's `return ...}` line rather than the first }.
```

The save-migrator example above only worked because `migrateSave` ends in a
top-level `return s}` with no nested `return` — don't rely on that luck.

## Modifying the artifact: the batch-patch loop

Large single-file artifacts are usually edited by applying a batch of
`(old_string, new_string)` replacements and writing once only if every one
matched. Two traps in that loop cost real debug cycles:

1. **All-or-nothing write discards the successes too.** If replacement #7 of
   12 fails to match and you (correctly) skip the write to avoid a
   half-patched file, the 11 that DID match are also thrown away — they only
   ever existed in your in-memory copy. The next attempt must re-apply the
   ENTIRE batch, not just the failed item. Track the full batch; never assume
   earlier "✅ applied" lines persisted. (This silently lost 11, then 8, then
   9 replacements across one game-patching session series — each time the
   "fix" re-applied only the failed item, and the feature stayed missing
   until the whole batch was re-run.)

2. **Verify against the file, not your memory.** After writing, re-read the
   artifact and assert a marker substring from each patch is present, then run
   the syntax check. "Applied ✅" printed from an in-memory `str.replace` is
   not evidence the file on disk changed — especially right after a prior
   failed batch.

3. **Build old_string from the file, not from memory.** After a skipped write
   or across sessions, your recalled text drifts — linebreaks, rule order,
   selector lists, trailing characters. A replacement built from memory fails
   to match, or worse, fuzzy-matches a *different* rule. Recovery: locate the
   block by index (`html.find('function renderResearch')`), find its end
   marker, slice the exact current text, print it to confirm, then build the
   replacement against that slice. (Lost a CSS-block patch this way: the
   remembered rule order differed from the file's actual order; the fix was
   to slice `.rpbar{...}` through the last `.tnode` rule verbatim.)

4. **Multi-selector CSS rules: preserve the full selector list.** Game CSS
   shares rules like `#view-station,#view-ship,#view-colony,#view-research{...}`.
   Replacing one with a narrower selector list silently strips layout from the
   dropped views — the write "succeeds" but other tabs break. Keep the
   original selector list intact and add a SEPARATE rule for the view you're
   changing. (Broke three views' base layout this way; had to restore the rule.)

## Workflow

1. **Extract the script** from the HTML:
   `html.match(/<script>([\s\S]*?)<\/script>/)[1]`
   (Multiple `<script>` blocks: extract each, test the one holding the logic.)

2. **Build stubs** — adapt `templates/dom-stub-harness.js` (ready-made stub
   layer): `document.getElementById` returning element stubs with classList /
   textContent / appendChild / blur / offsetWidth; canvas elements whose
   `getContext('2d')` returns a Proxy that no-ops every method AND accepts
   property sets (`ctx.fillStyle = ...`); `localStorage`, `performance.now`,
   `window.matchMedia`, an `AudioContext` that throws (so SFX paths exercise
   their try/catch), and `requestAnimationFrame` as a no-op — you drive the
   loop yourself.

3. **CONCATENATE stubs + artifact script + tests into ONE file, run with
   `node combined.js`.**
   ⚠️ Do NOT `eval()` the artifact script from the test file. `let` / `const` /
   function declarations inside `eval` stay trapped in the eval scope, so the
   test cannot read the artifact's state or call its functions — you get
   `ReferenceError` on the very first assertion. Concatenation puts everything
   in one scope. This is the #1 pitfall; it costs a full debug cycle.

4. **Drive the loop manually.** Call the artifact's loop function yourself with
   synthetic timestamps.
   ⚠️ Many loops clamp dt (e.g. `Math.min(dt, 100)`). ONE tick with a huge
   elapsed time will not advance state past the clamp — simulate MULTIPLE ticks
   (e.g. 12 × 100ms) instead of one 2-second jump.

5. **Assert on state**, iterate until green, then delete the temp
   stub/test/combined files — deliver only the artifact.

## VM context: run the real game code unmodified

When you need to drive the game's actual `tick()`, `battleSim()`, etc. (not
just one pure function), `vm.createContext` + `vm.runInContext` is superior to
concatenation: the game script runs in its own scope with all its `let`/`const`
declarations intact, and you access everything through the context object.

```js
const vm = require('vm');
const gameJs = html.match(/<script>([\s\S]*)<\/script>/)[1];

function makeEl() {
  return {
    innerHTML: '', textContent: '', value: '', style: {},
    classList: { add(){}, remove(){}, toggle(){} },
    querySelectorAll: () => [], querySelector: () => null,
    appendChild(){}, remove(){}, focus(){},
    dataset: {}, addEventListener(){},
    selectionStart: 0, selectionEnd: 0
  };
}
const ctx = vm.createContext({
  console, Math, JSON, Date, Object, Array, String, Number, Boolean,
  parseInt, parseFloat, isNaN, isFinite, Error, TypeError, ReferenceError,
  localStorage: { getItem: () => null, setItem: () => {} },
  document: {
    getElementById: () => makeEl(),
    querySelectorAll: () => [], querySelector: () => null,
    createElement: () => makeEl(),
    activeElement: null, addEventListener(){}
  },
  setInterval: () => {}, setTimeout: () => {}, window: {}
});
vm.runInContext(gameJs, ctx);
// Now ctx.S, ctx.tick, ctx.getBuildCost, etc. are all accessible
```

This avoids the `eval()` scope trap entirely — `let S` inside `runInContext`
is a context-level binding, readable as `ctx.S`. Use this for full game
simulation; use the `new Function` extraction (above) for single-function tests.

⚠️ **Multi-script artifacts: extract by id, not by position.** Once a game is
split into `<script id="game-logic">` plus a separate UI/three.js script (see
browser-game-development, "three.js 3D upgrade"), the naive
`/<script>([\s\S]*)<\/script>/` grabs garbage — the greedy match spans from
the FIRST `<script>` to the LAST `</script>`, swallowing the CDN tag and the
whole UI block. Anchor on the id:
`html.match(/<script id="game-logic">([\s\S]*?)<\/script>/)` and exit with an
error if it doesn't match.

⚠️ **The harness must call the game's init function.** When the logic script
deliberately does not self-start (it ends with `function gameInit(){...}` and
the UI script is what calls it — the standard split for testability),
`runInContext` alone leaves `ctx.S === null` and every assertion crashes on
the first state read. Add `vm.runInContext('gameInit();', ctx);` right after
loading, and print `!!ctx.S` as the smoke check before running any sim.

## Rendered-layout verification via headless Chrome (DOM probe)

Node-VM stubs cannot compute layout — they will never catch CSS overlap,
positioning, or visibility bugs. When the fix is visual (sidebar overlapping a
panel, element off-screen, badge hidden), verify in a real rendering engine
with a DOM probe: deterministic, parseable, and works even when you can't
visually inspect a screenshot (vision analysis unavailable, no display).

1. Copy the artifact (never probe the original). Replace `</body>` with a
   probe `<script>` that: (a) waits past game init via `setTimeout`,
   (b) measures `getBoundingClientRect()` of the involved elements,
   (c) simulates the interaction (`el.click()`), (d) waits for re-render and
   asserts DOM content (`innerHTML.indexOf(marker)>=0`), (e) encodes
   everything into `document.title='PROBE|key:value|...'`.
2. Run: `chrome --headless=new --disable-gpu --no-sandbox --virtual-time-budget=N --dump-dom "file:///..." | grep -o "<title>[^<]*</title>"`
3. Parse the pipe-separated fields. Overlap check = `right edge A > left edge B`.

Pitfalls:
- `--virtual-time-budget` must exceed the probe's TOTAL setTimeout chain, and
  the first delay must exceed the page's init/render time (validated: 6000+1500ms
  chain → budget 15000 works; too-small budget dumps the DOM before the probe
  sets the title).
- Percent-encode non-ASCII characters in `file://` URLs (Korean/CJK paths) or
  Chrome opens about:blank.
- If the user re-reports the same visual bug right after you fixed it: grep the
  file first — the fix is usually on disk but their browser shows a cached
  render. The probe settles whether it's actually rendered before re-editing.
- Companion gate after ANY patch to a multi-script single-file artifact:
  extract every `<script>` block with a global regex and parse each via
  `new Function(body)` — catches corruption a targeted grep misses.

Full recipe with exact validated commands: `references/layout-probe-chrome.md`.

## Balance simulation harness (strategy/sim games)

When the user says "playtest the balance yourself," write a sim that drives
the game's real `tick()` with a simulated player strategy over thousands of
ticks, then prints a balance report. Structure:

1. **Load game via VM context** (above).
2. **Write a sim strategy** — a `tryBuild(type)` / `tryTrain(type,count)` /
   `tryAttack(targetIdx)` layer that calls the game's real functions.
3. **Run in phases** (economy → military → expansion → endgame), printing
   state snapshots at each boundary.
4. **Print a balance report** with automated issue detection:
   - 0 victories → CRITICAL
   - loss rate >70% → WARNING
   - 0 conquests in 60min → WARNING
   - <3 quests completed → WARNING
   - <5 events → WARNING
   - 0 AI raids → WARNING (AI too passive)
   - pop overflow / negative resources → CRITICAL

### Sim strategy pitfalls (these are HARNESS bugs, not game bugs)

The sim strategy is itself code that can be wrong. These cost real debug cycles:

- **Queue depth limit is mandatory.** Without `if(S.buildQueue.length >= N) return false`,
  one building type (usually the cheapest resource building) fills the queue
  with 100+ items and nothing else ever gets built. Use N=3-5.
- **Don't queue duplicates.** `if(S.buildQueue.some(q => q.type===type)) return false`
  prevents the same building from occupying multiple queue slots.
- **Military unlock buildings must be tried EVERY phase**, not just phase 1.
  If `rally_point` is only attempted in phase 1 and the queue was full then,
  it's never built → 0 attacks for the entire sim. Add it to every phase's
  build priority list.
- **Attack thresholds must be achievable.** If the sim only attacks when
  `countAll(S.units) > 20` but AI raids kill units down to 3-5 every cycle,
  the threshold is never reached → 0 attacks. Lower thresholds (5-10) and
  check more frequently.
- **Warehouse/storage must keep pace.** If storage caps at 3000 but production
  is 2000+/tick, resources are perpetually capped and the sim can't afford
  anything. Include warehouse upgrades in every phase.
- **When the sim reports 0 attacks, check the SIM first, not the game.**
  Grep the game code for the attack function to confirm it exists, then trace
  why the sim never calls it. In practice, it's almost always a sim strategy
  bug (missing building, threshold too high, queue starvation).

## Verifying an applied fix batch (post-review-loop gate)

After applying a batch of reviewer-suggested fixes (recursive-improvement
rounds), don't rely on syntax checks + balance sim alone — write a throwaway
`verify_<round>.js` that loads the game via VM context (above) and asserts
EACH applied fix individually, printing a per-fix result and a final
`ALL FIXES VERIFIED` / exit-code gate. Structure per fix:

```js
// T1: rejected path returns error string; allowed path executes
out.T1_noMarket = doTrade('wood','clay',100);            // expect '시장을 먼저…'
S.buildings.market = 1;
out.T1_withMarket = doTrade('wood','clay',100) || 'OK';  // expect 'OK'
// T2: corrupted save gets healed
out.T2 = migrateSave({...JSON.parse(JSON.stringify(S)), speed:'abc'}).speed; // expect 1
// T3: race — arrival at now-owned territory returns home, no battle fired
// T4: new side-effect object appears in the expected list (e.g. defense report)
```

Rules that cost real cycles to learn:

- **Grep the ACTUAL function names before writing the script.** The review
  report and your memory both drift (`processArmyOut` vs the real
  `processArmies`). A `ReferenceError` on a misremembered name wastes a run.
- For state-dependent fixes, clone fresh state per test or order tests so
  earlier mutations can't mask later ones; print the raw values alongside the
  booleans so a failed gate is self-explanatory.
- Keep it a throwaway file in the project dir; `rm` it after the gate passes.

## Scenario checklist (games — adapt per artifact)

- Page load: initial draw must not crash before any user action (catches
  "state used before init" — a real bug found this way)
- Spawn / queue / bag randomizer
- Movement + collision vs walls and locked blocks
- Rotation incl. wall kicks
- Ghost/shadow projection
- Drop → lock → next spawn
- Hold/swap AND its one-shot lockout — test the lockout twice; a real bug
  found was a callee resetting the flag, allowing infinite reuse
- Scoring events and board mutation (rows actually removed, height preserved,
  counts incremented)
- Game-over detection + high-score persistence (localStorage)
- Pause/resume toggle

## Reading failures: harness bug vs real bug

When an assertion fails, decide which it is BEFORE touching the artifact:
- Recompute the expected value by hand. Harness arithmetic errors are common
  (e.g. expecting 4 surviving blocks when the scenario provably leaves 2, or
  asserting "still playing" after a scenario that legitimately ends the game).
- Real bugs confirmed by this method: uninitialized state used at page load;
  one-shot flag clobbered by a callee. Fix those in the artifact, re-run.
- **Stale assertion strings after multi-pass edits.** If you patch the artifact
  in several batches, an earlier test that greps for an exact substring can go
  false-negative when a LATER patch rewrites the surrounding text (the feature
  is still correct, the literal you grep for just moved). When a check fails,
  re-open the real artifact and confirm the behaviour is actually present
  before "fixing" anything — a count/regex over the whole file can also
  over-match (e.g. an achievement-ID regex that also catches tech/quest IDs).
  Treat the artifact file as ground truth, the test string as disposable.
- **Escaped-quote mismatches.** In a single-file HTML artifact the same class
  name appears twice: bare (`class="branch"`) inside the `<style>`/markup and
  escaped (`class=\"branch\"`) inside JS string concatenation. A test asserting
  one form only will false-negative even though the feature exists. Check both
  forms, or assert on something form-independent.
- **Scope count-regexes to the data literal.** `TECHS.length === 29` over the
  whole file broke the moment a later `TECHS.push(...)` legitimately added a
  30th entry. Anchor counts on the declaration
  (`script.match(/const TECHS=\[([\s\S]*?)\];/)[1]`) so push-appended items
  don't poison the base count — and test the push separately.

## Pitfalls

- `eval()` scope trap (step 3) — use `vm.createContext` + `vm.runInContext`
  for full-game simulation (preferred), or concatenation for simple cases.
- Single-tick dt clamp (step 4) — loop the ticks.
- Canvas 2D stub must handle method calls AND property assignment — a plain
  object of functions breaks on `ctx.fillStyle = x`. Use a Proxy.
- Element stubs need `offsetWidth`, `blur()`, `remove()` — animation-retrigger
  code (`void el.offsetWidth`) and button handlers touch these.
- Stub `window.AudioContext` to throw so sound code paths run their catch
  branch instead of silently skipping.

## Companion docs

- `references/balance-sim-harness.md` — full VM-context balance simulation
  template with strategy pitfalls (queue starvation, missing military unlocks,
  unreachable attack thresholds) and the automated issue-detection report.
- `references/layout-probe-chrome.md` — headless-Chrome DOM-probe recipe for
  verifying rendered layout fixes (overlap, positioning) without a browser
  automation tool; exact validated commands included.

