DEV Community

Cover image for Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds
Digital Craft Workshop
Digital Craft Workshop

Posted on Originally published at Medium

Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds

Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds

Not a member? Use this link.

I had ten thousand selectors sitting in a Postgres table and I was not sure the code that wrote them was still compatible with the code that read them. Parser strictness, schema renames, scoring algorithm changes. Any of them could have drifted between writer and reader.

Unit tests cannot cover this; fixtures only contain what I remembered to write. The honest move is to replay production data through current code and count what survives.

It took one CSV export and fifty lines of Node, in thirty seconds, to get a clear answer about compatibility over 10,000 records.


The setup

Export the stored fingerprints to a CSV:

COPY (
    SELECT id, fp
    FROM element_fingerprints
    ORDER BY id
    LIMIT 10000
) TO STDOUT WITH CSV HEADER;
Enter fullscreen mode Exit fullscreen mode

Each fp is a JSON blob like:

{
    "Ver": "v3",
    "Txt": "Submit",
    "Sels": [
        { "Sel": "button[type=\"submit\"]", "Prio": 2, "Count": 1, "Offset": 0, "IsStateful": false },
        { "Sel": ".form button",            "Prio": 2, "Count": 3, "Offset": 2, "IsStateful": false }
    ]
}
Enter fullscreen mode Exit fullscreen mode

The question I want to answer: can every stored selector still be parsed by the current code path? Every call ends in document.querySelector(sel), so what I actually need to check is whether the browser's CSS parser accepts the string.

You don't need a browser to answer that. JSDOM ships with a CSS selector engine that implements the same W3C Selectors grammar Chrome uses. That is enough to validate selector strings without booting a browser.

Replay pipeline diagram: Postgres COPY, CSV file, Node script, JSDOM parser, output tally

Postgres COPY to CSV to Node and JSDOM, ending in a compatibility tally | Generated with Claude


The script

const fs = require('fs');
const { JSDOM } = require('jsdom');
const doc = new JSDOM('<!DOCTYPE html><body></body>').window.document;

function parseCsv(text) {
    const rows = []; let field = '', row = [], inQuotes = false, i = 0;
    while (i < text.length) {
        const c = text[i];
        if (inQuotes) {
            if (c === "'" && text[i+1] === "'") { field += "'"; i += 2; continue; }
            if (c === "'") { inQuotes = false; i++; continue; }
            field += c; i++; continue;
        }
        if (c === "'") { inQuotes = true; i++; continue; }
        if (c === ',') { row.push(field); field = ''; i++; continue; }
        if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
        if (c === '\r') { i++; continue; }
        field += c; i++;
    }
    if (field || row.length) { row.push(field); rows.push(row); }
    return rows;
}

const rows = parseCsv(fs.readFileSync('./fingerprints.csv', 'utf8'));
rows.shift();

let totalSelectors = 0, invalidSelectors = 0;
let fullyCompatible = 0, partially = 0, allInvalid = 0, emptySels = 0;
const invalidExamples = [];

for (const [id, fpRaw] of rows) {
    if (!fpRaw || fpRaw === 'null') { emptySels++; continue; }
    const fp = JSON.parse(fpRaw);
    if (!Array.isArray(fp.Sels) || fp.Sels.length === 0) { emptySels++; continue; }

    let ok = 0, bad = 0;
    for (const s of fp.Sels) {
        totalSelectors++;
        try { doc.querySelector(s.Sel); ok++; }
        catch { bad++; invalidSelectors++; if (invalidExamples.length < 20) invalidExamples.push(s.Sel); }
    }

    if (ok === 0) allInvalid++;
    else if (bad > 0) partially++;
    else fullyCompatible++;
}

console.log({
    totalRows: rows.length,
    fullyCompatible,
    partially,
    allInvalid,
    emptySels,
    totalSelectors,
    invalidSelectors,
    invalidRatio: (invalidSelectors / totalSelectors * 100).toFixed(3) + '%',
    invalidExamples: invalidExamples.slice(0, 5)
});
Enter fullscreen mode Exit fullscreen mode

Run it:

node validate.js
Enter fullscreen mode Exit fullscreen mode

Output:

{
  totalRows: 10000,
  fullyCompatible: 9956,
  partially: 0,
  allInvalid: 0,
  emptySels: 44,
  totalSelectors: 221693,
  invalidSelectors: 0,
  invalidRatio: '0.000%',
  invalidExamples: []
}
Enter fullscreen mode Exit fullscreen mode

Terminal output: 10,000 rows, 9,956 fully compatible, zero parse failures across 221,693 selectors

The replay verdict from node validate.js | Generated with Claude


Why this matters more than unit tests

Your unit test suite probably has a dozen selector fixtures. Maybe a hundred. Production has patterns you didn't anticipate:

  • div[tracker="[object Object]"] — yes, someone's app stringified an object into an attribute
  • .dark\:border-gray-800 .w-full — escaped Tailwind variant colons
  • .group-hover\:bg-background-50 — 12 such classes chained together
  • label[for="mat-mdc-checkbox-10-input"] — Angular Material
  • label[for="radix-:r7:"] — Radix UI's colon-slug ids (these we filter out at capture, but old data still has them)

Most fixtures I've written are five or six selectors I remembered to type. The replay covered 221,693 stored ones at once.


What JSDOM catches that a type check won't

TypeScript checks the shape of fp.Sels[i] but not whether the string inside is a valid CSS selector. The CSS grammar has its own error modes: unclosed brackets, unknown pseudo-classes, malformed escapes. The only way to catch them cheaply is to run them through a parser.

The parser does a lot of small things you do not notice when you write a fixture. It tokenises the input, checks bracket balance, validates pseudo-class names, and either returns a node or throws. The throw is the only signal I care about here; the node lookup is a side effect.

That is why this loop scales linearly with selector count and not with DOM size. The DOM has one body element. The parser runs the full string-to-AST pipeline on every selector I feed it. On 221,693 selectors that pipeline took about thirty seconds on a laptop, single-threaded, no warm-up.

You might worry about using JSDOM's parser to stand in for the browser's. They're not literally the same implementation, but for CSS selector syntax both implement the W3C grammar. The invalid selectors JSDOM rejects are the ones Chrome will also reject. (If you want certainty, run the same script under puppeteer and call page.evaluate(sel => document.querySelector(sel)) — same result, minutes instead of seconds.)

Sidebar: this kind of "use a cheap tool to validate against reality" move is one of the patterns I keep coming back to as a solo builder. I have a free email series, AI as a Solo Founder's Tool, that walks through more of them.


Making it a CI job

The thirty-second runtime is the interesting part. This isn't a "once a quarter" audit — it's a cheap pre-deploy check you can run on every PR that touches the fingerprint code:

- name: Fingerprint compatibility check
  run: |
    psql -c "COPY (SELECT id, fp FROM fingerprints ORDER BY random() LIMIT 5000) TO STDOUT WITH CSV HEADER" > sample.csv
    node scripts/validate-fingerprints.js sample.csv --fail-on-regression
Enter fullscreen mode Exit fullscreen mode

I take a random sample of 5,000 from the production table and compare it against the baseline (fullyCompatible / totalRows). If the ratio drops by more than 1%, the PR fails. The threshold is tunable; I started at 1% and have not had to change it.


Generalizing the pattern

I use the same loop on more than fingerprints. Any time three things line up, the trick works:

  1. You have stored structured data (selectors, queries, configs, serialized state).
  2. Code consumes that data with a parser somewhere in the path.
  3. The parser can reject inputs the writer thought were valid.

Replay-validation triangle: stored data, code consumer, parser that can reject

The three conditions that make replay-based validation worth running | Generated with Claude

Whenever that triangle exists, replay-based validation is worth running. A handful of examples from our own codebase:

  • SQL queries stored for saved reports → run each through EXPLAIN and check for errors
  • Zod schemas versioned over time → re-validate last month's records against this month's schemas
  • Mongo aggregation pipelines saved in documents → dry-run them against db.runCommand({aggregate, explain:true})
  • Regex patterns stored in a rules engine → compile each; anything that throws is a stored record you can't read anymore

It's the cheapest form of backward-compatibility testing you'll ever write, and it's quantitative: instead of "I think it still works," you get "99.56% are fully compatible; the other 44 had empty selector lists I can investigate one at a time." It pairs naturally with a pre-push validation step that watches for the kind of drift this script measures.


External Sources


Production is the test suite I didn't write

I had a year of production data I had never used as a test. I left mine sitting in Postgres because some half-formed worry told me I could not run real data locally. I could. Thirty seconds and fifty lines of JavaScript. The replay took thirty seconds. I should have done it the first week.

If this kind of cheap reality-check is useful, I write up more of them in a free email series, AI as a Solo Founder's Tool.


I build small tools and kits for solo creators. You can find them here: https://danielrusnok.gumroad.com

Top comments (0)