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:
node --test runner and node:assert only.
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.
make test is the single definition of "the test suite". Agents aretold "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.
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.