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:
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:
// 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:
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.)
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.replaceis not evidence the file on disk changed — especially right after a prior failed batch.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.tnoderule verbatim.)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
Extract the script from the HTML:
html.match(/<script>([\s\S]*?)<\/script>/)[1](Multiple<script>blocks: extract each, test the one holding the logic.)Build stubs — adapt
templates/dom-stub-harness.js(ready-made stub layer):document.getElementByIdreturning element stubs with classList / textContent / appendChild / blur / offsetWidth; canvas elements whosegetContext('2d')returns a Proxy that no-ops every method AND accepts property sets (ctx.fillStyle = ...);localStorage,performance.now,window.matchMedia, anAudioContextthat throws (so SFX paths exercise their try/catch), andrequestAnimationFrameas a no-op — you drive the loop yourself.CONCATENATE stubs + artifact script + tests into ONE file, run with
node combined.js. ⚠️ Do NOTeval()the artifact script from the test file.let/const/ function declarations insideevalstay trapped in the eval scope, so the test cannot read the artifact's state or call its functions — you getReferenceErroron the very first assertion. Concatenation puts everything in one scope. This is the #1 pitfall; it costs a full debug cycle.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.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.
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).
- Copy the artifact (never probe the original). Replace
</body>with a probe<script>that: (a) waits past game init viasetTimeout, (b) measuresgetBoundingClientRect()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 intodocument.title='PROBE|key:value|...'. - Run:
chrome --headless=new --disable-gpu --no-sandbox --virtual-time-budget=N --dump-dom "file:///..." | grep -o "<title>[^<]*</title>" - Parse the pipe-separated fields. Overlap check =
right edge A > left edge B.
Pitfalls:
--virtual-time-budgetmust 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 vianew 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:
- Load game via VM context (above).
- Write a sim strategy — a
tryBuild(type)/tryTrain(type,count)/tryAttack(targetIdx)layer that calls the game's real functions. - Run in phases (economy → military → expansion → endgame), printing state snapshots at each boundary.
- 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 falseprevents the same building from occupying multiple queue slots. - Military unlock buildings must be tried EVERY phase, not just phase 1.
If
rally_pointis 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) > 20but 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:
// 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 (
processArmyOutvs the realprocessArmies). AReferenceErroron 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;
rmit 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 === 29over the whole file broke the moment a laterTECHS.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) — usevm.createContext+vm.runInContextfor 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.AudioContextto 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.