Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
^prettier\.config\.js$
^package-lock\.json$
^tsconfig\.json$
^tsconfig\.test\.json$
^CODE_OF_CONDUCT\.md$
^node_modules
^bin
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ JavaScript (npm; Node 22 works):
```bash
npm ci # install; postinstall also runs the build
npm run build-only # esbuild -> inst/reactlog/reactlogAsset/reactlog.js (no type check)
npm run typecheck # tsc, no output files
npm run build # eslint --fix + prettier on srcts, typecheck, then build
npm run typecheck # tsc for app code, then for tests (tsconfig.test.json); no output files
npm test # replay the sample logs in inst/log-files/ (node:test)
npm run build # eslint --fix + prettier on srcts, typecheck, test, then build
npm run watch # lint, then rebuild on change (no type check)
npm run lint -- srcts/foo.ts # eslint --fix + prettier on given files
npm run check-shiny-target # fail if tsc/esbuild target differs from rstudio/shiny (needs network)
Expand Down
6 changes: 3 additions & 3 deletions inst/reactlog/reactlogAsset/reactlog.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions inst/reactlog/reactlogAsset/reactlog.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion memory-bank/ciAndRelease.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Steps that may auto-commit, in order:
| `usethis::use_tidy_description()` | Reorders and reformats `DESCRIPTION` |
| `devtools::document()` | `DESCRIPTION`, `NAMESPACE`, `man/` |
| `oxipng` | Lossless PNG shrink on `man/figures/*.png` and `vignettes/*.png`. Git pathspec `*` also matches `/`, so this hits all 22 PNGs under `vignettes/images/` |
| `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` |
| `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` |

There is no `README.Rmd`, `codecov.yml`, or `# @staticimports`, so those steps skip.
After pushing, it runs `devtools::lint()` (because `.lintr` exists) and fails on
Expand Down
51 changes: 46 additions & 5 deletions memory-bank/jsBuild.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ blank lines.
| File | Role |
| --- | --- |
| `package.json` | npm scripts, `pre-commit` / `lint-staged` hook config, dependencies |
| `tsconfig.json` | `tsc` settings: `noEmit`, `target` es2021, `moduleResolution` bundler, `strict` on |
| `tsconfig.json` | `tsc` settings: `noEmit`, `target` es2021, `moduleResolution` bundler, `strict` on. Leaves out `srcts/__tests__/` |
| `tsconfig.test.json` | Extends `tsconfig.json`, adds the tests and `@types/node` |
| `srcts/modules.d.ts` | `declare module` for untyped `cytoscape` / `cytoscape-dagre`, and the `window` globals |
| `srcts/cyto/cytoFlowType.ts` | Hand-written types for the parts of cytoscape the app uses |
| `eslint.config.mjs` / `prettier.config.js` | Lint and format rules |
Expand All @@ -48,8 +49,15 @@ blank lines.
- `build-only`: `esbuild srcts/index.ts --bundle --minify --sourcemap
--target=es2021 --outfile=inst/reactlog/reactlogAsset/reactlog.js`. Takes
well under a second. esbuild strips types without checking them.
- `typecheck`: `tsc -p .`. Writes nothing.
- `build`: `lint-all`, then `typecheck`, then `build-only`. `lint-all` runs
- `typecheck`: `tsc -p .` (app code, browser types only), then
`tsc -p tsconfig.test.json` (app plus tests, with Node types). Writes
nothing.
- `test`: bundles `srcts/__tests__/graphReplay.test.ts` for Node with esbuild
(into `node_modules/.cache/reactlog-test/`) and runs it with plain `node`
(`node:test` still reports and sets the exit code). Not `node --test`:
newer Node treats its arguments as globs, which skip `node_modules/`.
See "Tests" below.
- `build`: `lint-all`, then `typecheck`, then `test`, then `build-only`. `lint-all` runs
`bin/lint_file.sh` on the `srcts` folder.
- `watch`: `lint-all` once, then `build-only -- --watch`. No type check while
watching; run `npm run typecheck` (or an editor) for that.
Expand Down Expand Up @@ -140,7 +148,7 @@ touch formatting or comments change the `.map` but not `reactlog.js`.

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

Expand Down Expand Up @@ -176,7 +184,40 @@ with their license text, under a header saying the package is MIT.
To check that a build change did not change behavior, load the old and new
bundle in turn and compare `window._reactlog.curTick`,
`window._reactlog.cyto.nodes().length` and the `#logEntry` text after the same
button clicks and key presses. There are no JS unit tests.
button clicks and key presses.

## Tests

`srcts/__tests__/graphReplay.test.ts` replays every `.js` log in
`inst/log-files/` through `GraphAtStep`. For each log it checks the step lists
(visible, idle, output calc, user mark), and at the first, middle, and last
visible step it checks the node and edge counts plus a fingerprint. Then it
filters on the first node and checks the filtered steps and the filtered graph
at the last of them. It runs in about 0.2 seconds, and `npm run build` runs it,
so CI's `routine` job does too. Adding a log file without adding its counts
fails the test.

- The fingerprint is a short sha256 of each node, edge, and ghost edge's key,
`cytoClasses`, and (for nodes) `cytoLabelShort`, the things cytoscape draws.
It catches wrong statuses that leave the counts the same.
- The graph modules read `window` when they load, and `rlog.ts` and
`GraphAtStep.ts` import each other. So the test imports `./setupWindow`
(a minimal `window` stub) first, then `../rlog`, then `GraphAtStep`. jQuery
and cytoscape load fine in Node without a DOM.
- `readLog` numbers the steps with `initStep()` from `log/initStep.ts`, the
same function the app uses.
- The log folder is found from the bundle's `__dirname`
(`node_modules/.cache/reactlog-test/`), so the bundle runs from any folder.
Move the `--outfile` and that path must change too.
- The counts and fingerprints are recorded from the code, not worked out by
hand. If a change moves them on purpose, check the viewer in a browser, then
copy the new values from the failing test's `actual` output.
- `@types/node` is `^22`, the oldest Node the build is known to work on, so
`tsc` flags Node APIs that are newer than that. Only the tests see these
types; `tsconfig.json` leaves `srcts/__tests__/` out, so app code cannot
use Node globals.
- Nothing tests the DOM or cytoscape drawing; use the browser comparison
above for that.

## Pitfalls

Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
"fix": "./bin/clean_rlog_file.sh",
"fix-all": "npm run fix -- srcts",
"lint-all": "npm run lint -- srcts",
"typecheck": "tsc -p .",
"build": "npm run lint-all && npm run typecheck && npm run build-only",
"typecheck": "tsc -p . && tsc -p tsconfig.test.json",
"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",
"build": "npm run lint-all && npm run typecheck && npm test && npm run build-only",
"build-only": "esbuild srcts/index.ts --bundle --minify --sourcemap --target=es2021 --outfile=inst/reactlog/reactlogAsset/reactlog.js",
"check-shiny-target": "node bin/check_shiny_target.js",
"watch": "npm run lint-all && npm run build-only -- --watch",
Expand All @@ -30,6 +31,7 @@
"@eslint/js": "^10.0.1",
"@types/jquery": "^3.5.34",
"@types/lodash": "^4.17.26",
"@types/node": "^22.20.5",
"esbuild": "^0.28.2",
"eslint": "^10.13.0",
"license-checker": "^25.0.1",
Expand Down
180 changes: 180 additions & 0 deletions srcts/__tests__/graphReplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Replays each sample log in inst/log-files/ and checks the graph at a few
// steps. Run with `npm test`. The counts and fingerprints were recorded from the
// current code. If a change moves them on purpose, check the viewer in a
// browser, then update them.

// Must load first: it sets the `window` that the modules below read on load
import "./setupWindow";
// Load rlog before GraphAtStep to match index.ts; they import each other
import "../rlog";

import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { test } from "node:test";

import { GraphAtStep } from "../graph/GraphAtStep";
import { initStep } from "../log/initStep";
import { mapValues } from "../utils/MapHelper";

import type { Graph } from "../graph/Graph";
import type { LogType } from "../log/logStates";

// [step, nodes, edges, unique edges, fingerprint]
type StepCounts = [number, number, number, number, string];

const expected: Record<
string,
{
entries: number;
visible: number;
idle: number;
outputCalc: number;
userMark: number;
// first, middle, and last visible step
steps: Array<StepCounts>;
// filtered on the first node: filtered visible steps, last filtered step
filtered: { visible: number; last: StepCounts };
}
> = {
"log-crandash.js": {
entries: 870,
visible: 595,
idle: 13,
outputCalc: 42,
userMark: 1,
steps: [
[5, 4, 0, 0, "bd1cbc0099a3"],
[461, 34, 18, 51, "20bae93dedf4"],
[869, 34, 42, 51, "da92d884a9c7"],
],
filtered: { visible: 403, last: [869, 20, 22, 22, "755aa90607e0"] },
},
"log-cranwhales.js": {
entries: 563,
visible: 413,
idle: 8,
outputCalc: 19,
userMark: 0,
steps: [
[48, 21, 0, 0, "f007434cc201"],
[305, 27, 12, 18, "847cb7277d81"],
[561, 27, 7, 18, "e6a31134268c"],
],
filtered: { visible: 264, last: [561, 9, 5, 10, "94e83ff5f8b0"] },
},
"log-linear-multi.js": {
entries: 830,
visible: 707,
idle: 5,
outputCalc: 5,
userMark: 0,
steps: [
[6, 3, 0, 0, "e679dbc65c6c"],
[398, 4, 54, 3, "cae70698f328"],
[828, 4, 11, 3, "4d833b8fdc7f"],
],
filtered: { visible: 707, last: [828, 3, 11, 3, "b8c2635ca126"] },
},
"log-radiant.js": {
entries: 6883,
visible: 3686,
idle: 40,
outputCalc: 347,
userMark: 0,
steps: [
[0, 0, 0, 0, "e3b0c44298fc"],
[4421, 1202, 451, 493, "eab4aad787a3"],
[6881, 1218, 29, 617, "a570269a0fa9"],
],
filtered: { visible: 10, last: [5862, 2, 0, 2, "eff96dd9436a"] },
},
"log-superzip.js": {
entries: 460,
visible: 318,
idle: 14,
outputCalc: 25,
userMark: 0,
steps: [
[31, 12, 0, 0, "165bcae050fa"],
[241, 28, 22, 32, "17df0cd0966e"],
[458, 28, 5, 32, "20ca22391be3"],
],
filtered: { visible: 4, last: [421, 1, 0, 0, "5bdd2edaf3a3"] },
},
};

// The bundle runs from node_modules/.cache/reactlog-test/
const logDir = path.join(__dirname, "..", "..", "..", "inst", "log-files");

// Each file is `window.log = [...];`
function readLog(file: string): LogType {
const text = fs.readFileSync(path.join(logDir, file), "utf8");
return initStep(
JSON.parse(
text.replace(/^window\.log\s*=\s*/, "").replace(/;\s*$/, "")
) as LogType
);
}

// Short hash of what cytoscape would draw: each element's key, classes, and
// label. Catches wrong statuses or edges that the counts alone would miss.
function fingerprint(g: Graph): string {
const lines = [
...mapValues(g.nodes).map(
n => `node ${n.key} ${n.cytoClasses} ${n.cytoLabelShort}`
),
...mapValues(g.edges).map(e => `edge ${e.key} ${e.cytoClasses}`),
...mapValues(g.edgesUnique).map(e => `ghost ${e.key} ${e.cytoClasses}`),
].sort();
return crypto
.createHash("sha256")
.update(lines.join("\n"))
.digest("hex")
.slice(0, 12);
}

function stepCounts(k: number, g: Graph): StepCounts {
return [k, g.nodes.size, g.edges.size, g.edgesUnique.size, fingerprint(g)];
}

test("every sample log has expected counts", () => {
const files = fs.readdirSync(logDir).filter(f => f.endsWith(".js"));
assert.deepEqual(files.sort(), Object.keys(expected).sort());
});

for (const [file, want] of Object.entries(expected)) {
test(`replays ${file}`, () => {
const log = readLog(file);
const graph = new GraphAtStep(log);

assert.equal(log.length, want.entries);
assert.equal(graph.stepsVisible.length, want.visible);
assert.equal(graph.stepsIdle.length, want.idle);
assert.equal(graph.stepsOutputCalc.length, want.outputCalc);
assert.equal(graph.stepsUserMark.length, want.userMark);

const visible = graph.stepsVisible;
const steps = [
visible[0],
visible[Math.floor(visible.length / 2)],
visible[visible.length - 1],
];
const got = steps.map(k => stepCounts(k, graph.rawGraphAtStep(k)));
assert.deepEqual(got, want.steps);

const firstNode = mapValues(graph.finalCompleteGraph.nodes)[0];
graph.updateFilterDatas([firstNode]);
const filtered = graph.filteredStepsVisible;
assert.ok(filtered.every(k => visible.includes(k)));
const last = filtered[filtered.length - 1];
assert.deepEqual(
{
visible: filtered.length,
last: stepCounts(last, graph.filteredGraphAtStep(last)),
},
want.filtered
);
});
}
5 changes: 5 additions & 0 deletions srcts/__tests__/setupWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// The app reads a few browser globals when its modules load. This is the
// smallest `window` that lets the graph code run under Node.
Object.assign(globalThis, {
window: { console, location: { search: "" }, log: [] },
});
13 changes: 10 additions & 3 deletions srcts/log/initStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import _map from "lodash/map";

import type { LogType } from "./logStates";

function initStep(log: LogType): LogType {
_map(log, function (entry, i: number) {
entry.step = i;
});
return log;
}

if (window.__APP_DATA__) {
window.log = window.__APP_DATA__;
}

_map(window.log as LogType, function (entry, i: number) {
entry.step = i;
});
initStep(window.log);

export { initStep };
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"isolatedModules": true,
"esModuleInterop": true
},
"include": ["srcts"]
"include": ["srcts"],
"exclude": ["srcts/__tests__"]
}
7 changes: 7 additions & 0 deletions tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jquery", "node"]
},
"exclude": []
}
Loading