Skip to content

Commit 660e3e9

Browse files
authored
test(srcts): Replay the sample logs in a Node test (#155)
* test(srcts): Replay the sample logs in a Node test srcts/__tests__/graphReplay.test.ts runs each log in inst/log-files/ through GraphAtStep and checks the step lists and the node and edge counts at the first, middle, and last visible step. `npm test` bundles it with esbuild and runs it with node --test; `npm run build` (and so CI's routine job) runs it too. * test(srcts): Run the bundled test with plain node Newer Node (CI uses the current release) treats `node --test` arguments as globs, and globs skip node_modules/, so it could not find the bundle. Running the file directly still reports through node:test and exits 1 on failure. * test(srcts): Fingerprint the graph, test filtering, and keep Node types out of app code - Split tsconfig: app code no longer sees @types/node; tsconfig.test.json adds the tests - Fingerprint each checked step from what cytoscape draws, so wrong statuses fail - Filter on the first node and check the filtered steps and graph - Number steps with initStep() instead of a copy - Ignore non-.js files in inst/log-files/ and find it from the bundle's folder
1 parent 7eac3e5 commit 660e3e9

13 files changed

Lines changed: 283 additions & 20 deletions

File tree

‎.Rbuildignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
^prettier\.config\.js$
1313
^package-lock\.json$
1414
^tsconfig\.json$
15+
^tsconfig\.test\.json$
1516
^CODE_OF_CONDUCT\.md$
1617
^node_modules
1718
^bin

‎AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ JavaScript (npm; Node 22 works):
1515
```bash
1616
npm ci # install; postinstall also runs the build
1717
npm run build-only # esbuild -> inst/reactlog/reactlogAsset/reactlog.js (no type check)
18-
npm run typecheck # tsc, no output files
19-
npm run build # eslint --fix + prettier on srcts, typecheck, then build
18+
npm run typecheck # tsc for app code, then for tests (tsconfig.test.json); no output files
19+
npm test # replay the sample logs in inst/log-files/ (node:test)
20+
npm run build # eslint --fix + prettier on srcts, typecheck, test, then build
2021
npm run watch # lint, then rebuild on change (no type check)
2122
npm run lint -- srcts/foo.ts # eslint --fix + prettier on given files
2223
npm run check-shiny-target # fail if tsc/esbuild target differs from rstudio/shiny (needs network)

‎inst/reactlog/reactlogAsset/reactlog.js‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎inst/reactlog/reactlogAsset/reactlog.js.map‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎memory-bank/ciAndRelease.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Steps that may auto-commit, in order:
9191
| `usethis::use_tidy_description()` | Reorders and reformats `DESCRIPTION` |
9292
| `devtools::document()` | `DESCRIPTION`, `NAMESPACE`, `man/` |
9393
| `oxipng` | Lossless PNG shrink on `man/figures/*.png` and `vignettes/*.png`. Git pathspec `*` also matches `/`, so this hits all 22 PNGs under `vignettes/images/` |
94-
| `npm ci` + `npm run build` | Uses Node `current`. `package.json` has a `build` script, so this runs (`npm ci` also runs `postinstall`, which builds). `build` runs `lint-all` (eslint `--fix` + prettier on `srcts/`), `typecheck` (tsc), then esbuild. Commits changes under `inst/` and `srcts/` plus `package-lock.json` |
94+
| `npm ci` + `npm run build` | Uses Node `current`. `package.json` has a `build` script, so this runs (`npm ci` also runs `postinstall`, which builds). `build` runs `lint-all` (eslint `--fix` + prettier on `srcts/`), `typecheck` (tsc), `test` (graph replay), then esbuild. Commits changes under `inst/` and `srcts/` plus `package-lock.json` |
9595

9696
There is no `README.Rmd`, `codecov.yml`, or `# @staticimports`, so those steps skip.
9797
After pushing, it runs `devtools::lint()` (because `.lintr` exists) and fails on

‎memory-bank/jsBuild.md‎

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ blank lines.
3131
| File | Role |
3232
| --- | --- |
3333
| `package.json` | npm scripts, `pre-commit` / `lint-staged` hook config, dependencies |
34-
| `tsconfig.json` | `tsc` settings: `noEmit`, `target` es2021, `moduleResolution` bundler, `strict` on |
34+
| `tsconfig.json` | `tsc` settings: `noEmit`, `target` es2021, `moduleResolution` bundler, `strict` on. Leaves out `srcts/__tests__/` |
35+
| `tsconfig.test.json` | Extends `tsconfig.json`, adds the tests and `@types/node` |
3536
| `srcts/modules.d.ts` | `declare module` for untyped `cytoscape` / `cytoscape-dagre`, and the `window` globals |
3637
| `srcts/cyto/cytoFlowType.ts` | Hand-written types for the parts of cytoscape the app uses |
3738
| `eslint.config.mjs` / `prettier.config.js` | Lint and format rules |
@@ -48,8 +49,15 @@ blank lines.
4849
- `build-only`: `esbuild srcts/index.ts --bundle --minify --sourcemap
4950
--target=es2021 --outfile=inst/reactlog/reactlogAsset/reactlog.js`. Takes
5051
well under a second. esbuild strips types without checking them.
51-
- `typecheck`: `tsc -p .`. Writes nothing.
52-
- `build`: `lint-all`, then `typecheck`, then `build-only`. `lint-all` runs
52+
- `typecheck`: `tsc -p .` (app code, browser types only), then
53+
`tsc -p tsconfig.test.json` (app plus tests, with Node types). Writes
54+
nothing.
55+
- `test`: bundles `srcts/__tests__/graphReplay.test.ts` for Node with esbuild
56+
(into `node_modules/.cache/reactlog-test/`) and runs it with plain `node`
57+
(`node:test` still reports and sets the exit code). Not `node --test`:
58+
newer Node treats its arguments as globs, which skip `node_modules/`.
59+
See "Tests" below.
60+
- `build`: `lint-all`, then `typecheck`, then `test`, then `build-only`. `lint-all` runs
5361
`bin/lint_file.sh` on the `srcts` folder.
5462
- `watch`: `lint-all` once, then `build-only -- --watch`. No type check while
5563
watching; run `npm run typecheck` (or an editor) for that.
@@ -140,7 +148,7 @@ touch formatting or comments change the `.map` but not `reactlog.js`.
140148

141149
`.Rbuildignore` keeps the JS toolchain out of the R tarball: `^srcts`,
142150
`^node_modules`, `^bin`, `^package\.json$`, `^package-lock\.json$`,
143-
`^tsconfig\.json$`, `^eslint\.config\.mjs$`, `^prettier\.config\.js$`,
151+
`^tsconfig\.json$`, `^tsconfig\.test\.json$`, `^eslint\.config\.mjs$`, `^prettier\.config\.js$`,
144152
`^inst/log-files`, and `.*\.js\.map$`. The last one means the source map is in
145153
git but not in the R package; the `sourceMappingURL` line still points to it.
146154

@@ -176,7 +184,40 @@ with their license text, under a header saying the package is MIT.
176184
To check that a build change did not change behavior, load the old and new
177185
bundle in turn and compare `window._reactlog.curTick`,
178186
`window._reactlog.cyto.nodes().length` and the `#logEntry` text after the same
179-
button clicks and key presses. There are no JS unit tests.
187+
button clicks and key presses.
188+
189+
## Tests
190+
191+
`srcts/__tests__/graphReplay.test.ts` replays every `.js` log in
192+
`inst/log-files/` through `GraphAtStep`. For each log it checks the step lists
193+
(visible, idle, output calc, user mark), and at the first, middle, and last
194+
visible step it checks the node and edge counts plus a fingerprint. Then it
195+
filters on the first node and checks the filtered steps and the filtered graph
196+
at the last of them. It runs in about 0.2 seconds, and `npm run build` runs it,
197+
so CI's `routine` job does too. Adding a log file without adding its counts
198+
fails the test.
199+
200+
- The fingerprint is a short sha256 of each node, edge, and ghost edge's key,
201+
`cytoClasses`, and (for nodes) `cytoLabelShort`, the things cytoscape draws.
202+
It catches wrong statuses that leave the counts the same.
203+
- The graph modules read `window` when they load, and `rlog.ts` and
204+
`GraphAtStep.ts` import each other. So the test imports `./setupWindow`
205+
(a minimal `window` stub) first, then `../rlog`, then `GraphAtStep`. jQuery
206+
and cytoscape load fine in Node without a DOM.
207+
- `readLog` numbers the steps with `initStep()` from `log/initStep.ts`, the
208+
same function the app uses.
209+
- The log folder is found from the bundle's `__dirname`
210+
(`node_modules/.cache/reactlog-test/`), so the bundle runs from any folder.
211+
Move the `--outfile` and that path must change too.
212+
- The counts and fingerprints are recorded from the code, not worked out by
213+
hand. If a change moves them on purpose, check the viewer in a browser, then
214+
copy the new values from the failing test's `actual` output.
215+
- `@types/node` is `^22`, the oldest Node the build is known to work on, so
216+
`tsc` flags Node APIs that are newer than that. Only the tests see these
217+
types; `tsconfig.json` leaves `srcts/__tests__/` out, so app code cannot
218+
use Node globals.
219+
- Nothing tests the DOM or cytoscape drawing; use the browser comparison
220+
above for that.
180221

181222
## Pitfalls
182223

‎package-lock.json‎

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
"fix": "./bin/clean_rlog_file.sh",
66
"fix-all": "npm run fix -- srcts",
77
"lint-all": "npm run lint -- srcts",
8-
"typecheck": "tsc -p .",
9-
"build": "npm run lint-all && npm run typecheck && npm run build-only",
8+
"typecheck": "tsc -p . && tsc -p tsconfig.test.json",
9+
"test": "esbuild srcts/__tests__/graphReplay.test.ts --bundle --platform=node --log-level=warning --outfile=node_modules/.cache/reactlog-test/graphReplay.test.cjs && node node_modules/.cache/reactlog-test/graphReplay.test.cjs",
10+
"build": "npm run lint-all && npm run typecheck && npm test && npm run build-only",
1011
"build-only": "esbuild srcts/index.ts --bundle --minify --sourcemap --target=es2021 --outfile=inst/reactlog/reactlogAsset/reactlog.js",
1112
"check-shiny-target": "node bin/check_shiny_target.js",
1213
"watch": "npm run lint-all && npm run build-only -- --watch",
@@ -30,6 +31,7 @@
3031
"@eslint/js": "^10.0.1",
3132
"@types/jquery": "^3.5.34",
3233
"@types/lodash": "^4.17.26",
34+
"@types/node": "^22.20.5",
3335
"esbuild": "^0.28.2",
3436
"eslint": "^10.13.0",
3537
"license-checker": "^25.0.1",
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Replays each sample log in inst/log-files/ and checks the graph at a few
2+
// steps. Run with `npm test`. The counts and fingerprints were recorded from the
3+
// current code. If a change moves them on purpose, check the viewer in a
4+
// browser, then update them.
5+
6+
// Must load first: it sets the `window` that the modules below read on load
7+
import "./setupWindow";
8+
// Load rlog before GraphAtStep to match index.ts; they import each other
9+
import "../rlog";
10+
11+
import assert from "node:assert/strict";
12+
import crypto from "node:crypto";
13+
import fs from "node:fs";
14+
import path from "node:path";
15+
import { test } from "node:test";
16+
17+
import { GraphAtStep } from "../graph/GraphAtStep";
18+
import { initStep } from "../log/initStep";
19+
import { mapValues } from "../utils/MapHelper";
20+
21+
import type { Graph } from "../graph/Graph";
22+
import type { LogType } from "../log/logStates";
23+
24+
// [step, nodes, edges, unique edges, fingerprint]
25+
type StepCounts = [number, number, number, number, string];
26+
27+
const expected: Record<
28+
string,
29+
{
30+
entries: number;
31+
visible: number;
32+
idle: number;
33+
outputCalc: number;
34+
userMark: number;
35+
// first, middle, and last visible step
36+
steps: Array<StepCounts>;
37+
// filtered on the first node: filtered visible steps, last filtered step
38+
filtered: { visible: number; last: StepCounts };
39+
}
40+
> = {
41+
"log-crandash.js": {
42+
entries: 870,
43+
visible: 595,
44+
idle: 13,
45+
outputCalc: 42,
46+
userMark: 1,
47+
steps: [
48+
[5, 4, 0, 0, "bd1cbc0099a3"],
49+
[461, 34, 18, 51, "20bae93dedf4"],
50+
[869, 34, 42, 51, "da92d884a9c7"],
51+
],
52+
filtered: { visible: 403, last: [869, 20, 22, 22, "755aa90607e0"] },
53+
},
54+
"log-cranwhales.js": {
55+
entries: 563,
56+
visible: 413,
57+
idle: 8,
58+
outputCalc: 19,
59+
userMark: 0,
60+
steps: [
61+
[48, 21, 0, 0, "f007434cc201"],
62+
[305, 27, 12, 18, "847cb7277d81"],
63+
[561, 27, 7, 18, "e6a31134268c"],
64+
],
65+
filtered: { visible: 264, last: [561, 9, 5, 10, "94e83ff5f8b0"] },
66+
},
67+
"log-linear-multi.js": {
68+
entries: 830,
69+
visible: 707,
70+
idle: 5,
71+
outputCalc: 5,
72+
userMark: 0,
73+
steps: [
74+
[6, 3, 0, 0, "e679dbc65c6c"],
75+
[398, 4, 54, 3, "cae70698f328"],
76+
[828, 4, 11, 3, "4d833b8fdc7f"],
77+
],
78+
filtered: { visible: 707, last: [828, 3, 11, 3, "b8c2635ca126"] },
79+
},
80+
"log-radiant.js": {
81+
entries: 6883,
82+
visible: 3686,
83+
idle: 40,
84+
outputCalc: 347,
85+
userMark: 0,
86+
steps: [
87+
[0, 0, 0, 0, "e3b0c44298fc"],
88+
[4421, 1202, 451, 493, "eab4aad787a3"],
89+
[6881, 1218, 29, 617, "a570269a0fa9"],
90+
],
91+
filtered: { visible: 10, last: [5862, 2, 0, 2, "eff96dd9436a"] },
92+
},
93+
"log-superzip.js": {
94+
entries: 460,
95+
visible: 318,
96+
idle: 14,
97+
outputCalc: 25,
98+
userMark: 0,
99+
steps: [
100+
[31, 12, 0, 0, "165bcae050fa"],
101+
[241, 28, 22, 32, "17df0cd0966e"],
102+
[458, 28, 5, 32, "20ca22391be3"],
103+
],
104+
filtered: { visible: 4, last: [421, 1, 0, 0, "5bdd2edaf3a3"] },
105+
},
106+
};
107+
108+
// The bundle runs from node_modules/.cache/reactlog-test/
109+
const logDir = path.join(__dirname, "..", "..", "..", "inst", "log-files");
110+
111+
// Each file is `window.log = [...];`
112+
function readLog(file: string): LogType {
113+
const text = fs.readFileSync(path.join(logDir, file), "utf8");
114+
return initStep(
115+
JSON.parse(
116+
text.replace(/^window\.log\s*=\s*/, "").replace(/;\s*$/, "")
117+
) as LogType
118+
);
119+
}
120+
121+
// Short hash of what cytoscape would draw: each element's key, classes, and
122+
// label. Catches wrong statuses or edges that the counts alone would miss.
123+
function fingerprint(g: Graph): string {
124+
const lines = [
125+
...mapValues(g.nodes).map(
126+
n => `node ${n.key} ${n.cytoClasses} ${n.cytoLabelShort}`
127+
),
128+
...mapValues(g.edges).map(e => `edge ${e.key} ${e.cytoClasses}`),
129+
...mapValues(g.edgesUnique).map(e => `ghost ${e.key} ${e.cytoClasses}`),
130+
].sort();
131+
return crypto
132+
.createHash("sha256")
133+
.update(lines.join("\n"))
134+
.digest("hex")
135+
.slice(0, 12);
136+
}
137+
138+
function stepCounts(k: number, g: Graph): StepCounts {
139+
return [k, g.nodes.size, g.edges.size, g.edgesUnique.size, fingerprint(g)];
140+
}
141+
142+
test("every sample log has expected counts", () => {
143+
const files = fs.readdirSync(logDir).filter(f => f.endsWith(".js"));
144+
assert.deepEqual(files.sort(), Object.keys(expected).sort());
145+
});
146+
147+
for (const [file, want] of Object.entries(expected)) {
148+
test(`replays ${file}`, () => {
149+
const log = readLog(file);
150+
const graph = new GraphAtStep(log);
151+
152+
assert.equal(log.length, want.entries);
153+
assert.equal(graph.stepsVisible.length, want.visible);
154+
assert.equal(graph.stepsIdle.length, want.idle);
155+
assert.equal(graph.stepsOutputCalc.length, want.outputCalc);
156+
assert.equal(graph.stepsUserMark.length, want.userMark);
157+
158+
const visible = graph.stepsVisible;
159+
const steps = [
160+
visible[0],
161+
visible[Math.floor(visible.length / 2)],
162+
visible[visible.length - 1],
163+
];
164+
const got = steps.map(k => stepCounts(k, graph.rawGraphAtStep(k)));
165+
assert.deepEqual(got, want.steps);
166+
167+
const firstNode = mapValues(graph.finalCompleteGraph.nodes)[0];
168+
graph.updateFilterDatas([firstNode]);
169+
const filtered = graph.filteredStepsVisible;
170+
assert.ok(filtered.every(k => visible.includes(k)));
171+
const last = filtered[filtered.length - 1];
172+
assert.deepEqual(
173+
{
174+
visible: filtered.length,
175+
last: stepCounts(last, graph.filteredGraphAtStep(last)),
176+
},
177+
want.filtered
178+
);
179+
});
180+
}

‎srcts/__tests__/setupWindow.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// The app reads a few browser globals when its modules load. This is the
2+
// smallest `window` that lets the graph code run under Node.
3+
Object.assign(globalThis, {
4+
window: { console, location: { search: "" }, log: [] },
5+
});

0 commit comments

Comments
 (0)