Background
I build small single-file browser tools. One HTML file, one inline <script>, no build step, no bundler.
I like them, but for a long time I did not really test them. The logic lives inside a <script> tag, so the obvious options were to pull the functions into a separate module (which means the page now needs a build step) or to drive a headless browser (which means a much heavier toolchain than the thing being tested).
So I kept a copy of the logic in a test file instead. That copy drifted, of course. I fixed a parsing bug in the page and the test kept passing against the old code, which is worse than having no test at all.
Today I tried a third option and it turned out to be about twenty lines.
Read the script back out of the built file
The trick is to stop treating the HTML as opaque. Extract the inline script from the file you are going to deploy, and run that string.
const fs = require('fs');
const vm = require('vm');
function loadScript(file) {
const html = fs.readFileSync(file, 'utf8');
// every inline <script> with no attributes; the tool logic is the last one
const m = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
return m[m.length - 1][1];
}
matchAll with that pattern deliberately skips <script src=...> and <script type="application/ld+json">, because those carry attributes. What comes back is the exact source that the browser will execute.
A DOM stub small enough to read
The script only touches document.getElementById, element .value / .textContent / .innerHTML / .style, and addEventListener. That is the whole surface, so that is all the stub needs.
function makeDom(ids) {
const els = {};
for (const id of ids) {
els[id] = {
id, value: '', textContent: '', innerHTML: '', style: {}, _h: {},
addEventListener(ev, fn) { (this._h[ev] = this._h[ev] || []).push(fn); },
click() { (this._h.click || []).forEach(f => f.call(this)); },
fire(ev) { (this._h[ev] || []).forEach(f => f.call(this)); }
};
}
return { document: { getElementById: id => els[id] || null }, els,
atob: s => Buffer.from(s, 'base64').toString('binary'),
TextDecoder, Uint8Array, console };
}
_h collects the handlers the script registers, and fire('input') calls them the way a real keystroke would. atob is not in Node's global scope in the same shape the page expects, so I hand it in explicitly. document here is the entire DOM as far as the tool is concerned.
Then run the script inside that object as its global scope, and drive it:
const ctx = makeDom(['src', 'sKind', 'sMid', 'sIssues', 'findWrap', 'terms', 'sampleSpf']);
vm.createContext(ctx);
vm.runInContext(loadScript('public/tools/spfdmarc/index.html'), ctx);
ctx.els.sampleSpf.click(); // press the "load sample" button
assert.strictEqual(ctx.els.sMid.textContent, '3 / 10');
vm.createContext turns the plain object into a sandbox global, so when the script says document.getElementById('src') it reaches the stub. Nothing is mocked at the module level and nothing is imported, because the script under test does not import anything.
What it caught
The tool being tested counts how much of the SPF ten DNS lookup budget a record spends. I wrote assertions for the cases I actually cared about: include plus a plus mx costs 3 while ip4 and ip6 cost nothing, eleven includes must be reported as over the limit, +all must be flagged, and a valid DMARC record must produce zero findings.
Two of the seventeen assertions failed on the first run. One was a real defect in the shipped page, where a description lookup pointed at the wrong table and rendered the DMARC version text for an SPF record. That is exactly the class of bug a duplicated test file never sees, because the duplicate would have had the same wrong lookup or, more likely, not have had that line at all.
The other failure was my own stub being stricter than a browser: it stores whatever the script assigns, so textContent held the number 3 where a real DOM would have coerced it to the string '3'. Worth knowing before you spend ten minutes hunting a bug that is not there. If that bothers you, make the stub coerce on assignment with a setter.
The limits, honestly
This does not render anything. Layout, CSS, focus behaviour and real event ordering are all outside it, so it is not a replacement for looking at the page. It also assumes the tool keeps its logic in one inline script, which is true for this kind of page and false for most applications.
What it does buy is that the tested code and the deployed code are the same bytes. For a page with no build step that is the whole problem solved, and the cost is one small file and no new dependencies.
If you ship single-file tools and have been telling yourself they are too small to test, they are probably also too small to need anything bigger than this. The finished tool is at https://hashitosystem.com/tools/spfdmarc/ if you want to see the shape of the thing being tested.
This article is about my own side project. It was written with AI assistance.
Top comments (0)