← all tickets

68 — node:test harness for static JS (zero-npm)

Status: done

Where this came from: the 2026-08-04 grilling session on ticket 67. The cards page's filter/counter fix needs behavioral JS tests, and the repo's source-scanner guards (_js_fn/_js_code/_matching in tests/test_static_pages.py) exist only because JS was untestable here — seven of ticket 49's nine round-3 findings were bugs in the scanners themselves. Node v20.19.2 is now installed (apt) and the harness direction is decided. Tickets 66 and 67 are blocked on this one.

Decisions already made — do not re-litigate:

No package.json, no lockfile, no jsdom, no npm dependency ever. If a test needs a DOM, the code under test is factored wrong.

ES modules exporting data-in/data-out functions; the glue that reads inputs and writes innerHTML stays small and untested (the same deal the Rust side makes with PyO3 glue).

manager, no version file. Document the requirement; don't tool it.

told "run the test suite"; after this ticket that phrase has exactly one referent.

Scope:

1. Makefile targets. py-test ($(VENV)/pytest -q), js-test (node --test tests/js/), and the umbrella `test: rust-test py-test js-test`. Keep the existing targets untouched.

2. Docs. One sentence in CLAUDE.md: the test suite is make test (cargo + pytest + node --test), all three green. Add node ≥ 20 (apt) to wherever the venv/cargo setup is described. docs/adding-a-card.md's definition of done needs no change — "an automated test" now simply has a third place to live.

3. Extract static/cards.js logic into an ES module. The pure parts — the filter predicate chain inside applyFilters(), the summary math in buildSummary(), statusCells()'s payload→cells mapping — move to an exported module (e.g. static/cards-logic.js); cards.js becomes the glue that reads the DOM inputs and renders. static/cards.html loads cards.js with type="module"; the classic scripts before it (card-tooltip.js, netrunner-common.js) stay global — a module can read globals fine. Watch two things: module scripts are deferred, so init()'s timing must still work; and _versioned()/_LOCAL_ASSET in server/routers/pages.py only recognizes src="/static/<flat>.js" double-quoted with no query (ticket 66, finding 9) — keep the tag in that shape so cache busting keeps working.

4. Migrate the cards-page scanner guards to behavioral node tests. The tests/test_static_pages.py checks that assert *logic* properties of cards.js (statusCells never branches on c.type, never recomputes has_tests/tested_manually/vanilla, one <td> per header, etc.) become real node:test cases against the extracted module, and their scanner call sites are deleted. The scanner helpers themselves stay for now — editor.js and game.js guards still use them; retiring those is ticket 66's re-scoped job. Structural checks that genuinely concern the served page (headers, asset versioning) stay in pytest.

5. Definition of done: from a clean checkout with apt node, the venv and cargo, make test runs all three suites and is green. tests/js/ holds at least the migrated cards-page tests. The pytest count will drop by the migrated guards — record the delta in this ticket, it is movement, not loss.

Blocked by: none.

Comments

2026-08-04 — landed. make test = rust-test + py-test + js-test: 284 cargo + 258 pytest + 23 node, all green.

Test-count delta: pytest 261 → 258. Four cards-page scanner guards were deleted and their assertions re-made behaviorally in tests/js/cards-logic.test.mjs (test_the_status_cells_are_painted_in_the_order_the_payload_sets, test_the_columns_read_the_payload_instead_of_the_card_type, test_an_unimplemented_card_is_muted_whatever_its_type, test_a_body_row_holds_exactly_one_cell_per_header); test_the_na_badge_is_named_for_what_it_means lost its two source-scanning halves and kept the served-page one as test_the_na_badge_has_a_style_to_paint_it; and one test was added (test_every_module_the_cards_script_imports_is_versioned_too). 4 out, 1 in. The 23 node tests are the movement: the migrated guards became ten (badges, status cells, table row), and the filter and summary logic they never reached at all got thirteen more.

What the split looks like. static/cards-logic.mjs exports badge, statusCells, cardRow, filterCards and summarize; static/cards.js is the glue (getElementById, fetch, innerHTML =) and is loaded with <script type="module">. render()'s row template moved into the module too — scope item 4 asks for "one <td> per header" as a behavioral test, and that needs the row's actual output. Browser globals (esc, PACK_ABBR, cardHasSubtype) are passed in as arguments, and tests/js/helpers/globals.mjs pulls the real ones out of netrunner-common.js with node:vm, so no test asserts against a lookalike.

One thing beyond the letter of the scope, flagged here. A module's own import never passes through the server, so ./cards-logic.mjs would have been served with no ?v= at all — ticket 49's stale-script bug one level down, introduced by the very act of splitting the file. static/cards.html now carries a one-entry import map and _versioned() stamps its value (key bare, value hashed) via a new _IMPORT_MAP_ENTRY pattern; _LOCAL_ASSET also learned .mjs. That is ticket 66's finding 9 territory, which 68 was told to touch if it needed to — **ticket 66 should re-read server/routers/pages.py before starting.** The quote/query/flat-path limits finding 9 lists are untouched and still that ticket's.

Extension choice: .mjs, not .js. Node treats a bare .js as CommonJS without a package.json, and the zero-npm contract says there is no package.json; node 20.19 happens to sniff module syntax and get it right, but that would have quietly made the dependency "node ≥ 20.19" instead of the "node ≥ 20" this ticket decided. .mjs works on any node and serves as text/javascript (checked against the running app).

2026-08-04 — review round, four test-contract gaps closed. All four were tests that would have stayed green through a real regression; each fix was confirmed by making the mutation and watching the suite go red.

1. No test pinned that cardRow() embeds statusCells() — the row tests counted cells or passed status_columns: [], so emitting <td></td> per status column kept them green while the served table showed blank status columns. `a body row carries the status badges after the cells naming the card` asserts the row's full cell contents, badges included, and their order. 2. test_every_module_the_cards_script_imports_is_versioned_too asserted a substring of the import map. Invalid JSON around it (trailing comma, missing brace) kept the substring and the test, while the browser discards an unparseable map whole and fetches the module unversioned — the exact bug the map exists to prevent. It now extracts the type="importmap" body and json.loads it (_import_map()). 3. Nothing pinned the umbrella's composition, so test: rust-test py-test would leave every suite green while the JS tests stopped running for CI and for every agent told "run the test suite". tests/test_make_targets.py asks make --dry-run test what it expands to — behavior, not the Makefile's text. 4. Nothing fetched the new .mjs over HTTP: correct URLs and an existing file still ship nothing runnable if the static route serves a MIME type a browser refuses to execute as a module. test_the_modules_the_cards_page_maps_are_served_as_javascript fetches the URL the import map hands the browser and checks the status, content type and body.

Counts after this round: cargo 284, pytest 263 (+5), node 24 (+1). One further finding — that helpers/globals.mjs has no test proving it loads the real classic script — was rejected: the helper already throws ReferenceError when a name moves, and pinning "a future edit could swap in doubles" would be another source-grep test, which is what this ticket removed.

Left for ticket 66: _js_fn/_matching still back the editor.js and game.js guards, so both stay. _js_code now has no call site outside its own three self-tests — it is the next helper to go when those guards move.