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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ big_flu
s3/
/local_narratives/
/datasets/
/test/profiling/baselines/
!test/profiling/baselines/.gitkeep

### OSX ###
.DS_Store
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
This functionality was deprecated in version 2.44.0.
[#2082](https://github.com/nextstrain/auspice/pull/2082)

* (Dev-only) Performance harnesses to profile function times and render-equivalence added.
[#2078](https://github.com/nextstrain/auspice/pull/2078)

* (Dev-only) We no longer use Heroku for Auspice-specific review apps, instead leveraging nextstrain.org and auspice.us review apps for testing purposes. [#2067](https://github.com/nextstrain/auspice/pull/2067)

* (Dev-only) Auspice is now released via [a Github Actions workflow](https://github.com/nextstrain/auspice/actions/workflows/release.yaml).
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"test": "jest test/*.js test/*.ts",
"test:package": "./scripts/test-npm-package",
"smoke-test": "NODE_ENV=test ENV=dev npx playwright test",
"profile": "node test/profiling/runProfiling.mjs",
"render-equiv": "node test/profiling/renderEquivalence.mjs",
"diff-lang": "./scripts/diff-lang.js"
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions scripts/fetch-test-data
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const datasets = [
{ src: 'ebola/ebov-2013@2025-10-14', dst: 'ebola-ebov-2013' },
{ src: 'mumps/global@2026-05-30', dst: 'mumps', tipFrequencies: true },
{ src: 'zika@2026-06-05', dst: 'zika'},
{ src: 'groups/trajectories/spike-sm', dst: 'spike-sm'},
];

async function main() {
Expand Down
2 changes: 2 additions & 0 deletions src/components/tree/phyloTree/change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const modifySVG = function modifySVG(
transitionTime: number,
extras: Extras,
): void {
timerStart("modifySVG");
let updateCall: UpdateCall;
const classesToPotentiallyUpdate: TreeElement[] = [".tip", ".vaccineDottedLine", ".vaccineCross", ".branch"]; /* order is respected */
/* treat stem / branch specially, but use these to replace a normal .branch call if that's also to be applied */
Expand Down Expand Up @@ -222,6 +223,7 @@ export const modifySVG = function modifySVG(
} else {
this.removeMeasurementsColoringCrosshair();
}
timerEnd("modifySVG");
};

/* instead of modifying the SVG the "normal" way, this is sometimes too janky (e.g. when we need to move everything)
Expand Down
1 change: 1 addition & 0 deletions src/components/tree/phyloTree/layouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ export const mapToScreen = function mapToScreen(this: PhyloTreeType): void {
if (this.params.showStreamTrees) {
this.mapStreamsToScreen()
}
timerEnd("mapToScreen");
};

/**
Expand Down
1 change: 1 addition & 0 deletions test/profiling/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.server.log
83 changes: 83 additions & 0 deletions test/profiling/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Auspice profiling

There are two harnesses in `test/profiling/`:

1. `npm run profile` which collects JS timing measurements across a number of example datasets & actions.
Its intention is to be run against different versions of the code to see any performance changes.
2. `npm run render-equiv` is a render-equivalence suite to check that in-app actions produce the exact same SVG DOM as a from-scratch full render of the same end state.

## Timing profiles

A headless, reproducible harness that measures Auspice's built-in `src/util/perf.js`
timers (`timerStart`/`timerEnd`) across representative datasets and produces a ranked
baseline of where time is spent. **Measure-only** — it changes no application code.

#### Quick start

```bash
# 1. build (--includeTiming, production) + serve data/ + run all scenarios
npm run profile -- --build

# subsequent runs can skip the rebuild if dist/ already has timers:
npm run profile

# compare a run against a previous baseline (before/after):
npm run profile -- --baseline test/profiling/baselines/baseline-latest.json

# emulate a slower device (also lifts small-tree spans above perf.js's 20ms floor):
npm run profile -- --throttle 4

# run a subset:
npm run profile -- --only zika-load,spike-animation
```

Outputs land in `test/profiling/baselines/`:
- `baseline-<gitSha>.json` and `baseline-latest.json` — machine-readable (per
scenario × marker × span: median/p95/count/total + raw samples).
- `report-<gitSha>.md` — ranked hotspot report (also printed to stdout).

#### How it works

- **Timers must be compiled in.** Normal builds strip `timerStart`/`timerEnd`
(`babel.config.cjs`). `cli/build.ts --includeTiming` keeps them in a **production,
minified** bundle. The harness runs that build and serves it with `auspice view`
— production-representative numbers, *not* `auspice develop` (dev mode distorts
timings). It asserts the served bundle actually contains timer output or fails loudly.
- **Serving `data/`.** The stock `playwright.config.ts` webServer only serves
`test/data test/fetched-jsons`; this harness serves the top-level `data/` dir too
(so `/spike-sm` etc. resolve). It reuses an already-running :4000 server if present.
- **Capturing timers.** `page.on('console')` accepts both `log` and `warning`
(perf.js uses `console.warn` for calls >20ms). It records the per-call `took`
value and ignores perf.js's cumulative `Average`.
- **Isolation.** A fresh browser context per trial resets perf.js's accumulator, so
samples never bleed across scenarios. Each sample is tagged with the interaction
marker in flight.
- **Driving interactions.** Most spans are measured via URL-driven loads (a load with
`?l=radial` runs the same layout/mapToScreen code as an in-app change). The
incremental `phylotree.change()` cascade is captured by the URL-driven `animate`
scenario. DOM-click steps (colorby/layout) are best-effort extras that skip
gracefully if a control can't be driven.
- **Render-complete signal.** Waits for the `phyloTree render()` timer line (not
`networkidle`, which is meaningless for a 35k-tip D3 render), with a
`svg#MainTree circle.tip` DOM fallback.



## Render-equivalence suite

A regression suite (`renderEquivalence.mjs` + `domSnapshot.mjs`) that guards the
incremental tree-update path. For each operation (colorBy, layout, distance, filter,
zoom, confidence, …) and for sequences of operations, it drives the **incremental**
update in-app — via `history.pushState` + a `popstate` event, which the app's own
listener turns into an incremental `phylotree.change()` (zero source changes) — and
asserts the settled SVG DOM is **identical** to a from-scratch **full render** of the
same end state (a fresh page at the app's resulting URL). Any mismatch is a stale-DOM
regression.

```bash
npm run render-equiv # reuse current dist/ build
npm run render-equiv -- --build # force a fresh build first
npm run render-equiv -- --only ebola-zoom,ebola-filter-date-colorby
```

Run it after any change to the tree render/update code.
57 changes: 57 additions & 0 deletions test/profiling/aggregate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Aggregate flat timer samples into per-(scenario x marker x span) statistics.
* Uses the per-call `took` values; reports median/p95 (robust to outliers)
* rather than mean.
*/

function median(sorted) {
const n = sorted.length;
if (n === 0) return 0;
return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
}

function percentile(sorted, p) {
const n = sorted.length;
if (n === 0) return 0;
const idx = Math.min(n - 1, Math.max(0, Math.ceil(p * n) - 1));
return sorted[idx];
}

function stats(values) {
const sorted = [...values].sort((a, b) => a - b);
return {
count: sorted.length,
median: median(sorted),
p95: percentile(sorted, 0.95),
min: sorted[0] ?? 0,
max: sorted[sorted.length - 1] ?? 0,
total: values.reduce((a, b) => a + b, 0),
};
}

// Field delimiter for group keys. Span names contain spaces (e.g.
// "phyloTree render()"), so we use the NUL char, which cannot appear in a field.
// Built via fromCharCode to keep the source file pure ASCII (no raw NUL byte).
const SEP = String.fromCharCode(0);

/** @returns {Array<{scenario,marker,name,count,median,p95,min,max,total,samples}>} */
export function aggregate(samples) {
const real = samples.filter((s) => typeof s.took === "number" && !s.name.startsWith("__"));
const groups = new Map();
for (const s of real) {
const key = `${s.scenario}${SEP}${s.marker}${SEP}${s.name}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(s.took);
}
const rows = [];
for (const [key, vals] of groups) {
const [scenario, marker, name] = key.split(SEP);
rows.push({ scenario, marker, name, ...stats(vals), samples: vals });
}
rows.sort((a, b) => b.median - a.median);
return rows;
}

export function rowKey(r) {
return `${r.scenario}${SEP}${r.marker}${SEP}${r.name}`;
}
Empty file.
113 changes: 113 additions & 0 deletions test/profiling/buildAndServe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Orchestrate a timers-enabled production build and an `auspice view` server.
*
* - ensureTimingBuild(): guarantees dist/ holds a `--includeTiming` production
* bundle (rebuilds if missing/stripped), asserting timers survived.
* - startServer()/stopServer(): serve the top-level `data/` dir (which the stock
* playwright.config.ts webServer does NOT do) on :4000, reusing an already
* running server if present.
*/

import { spawn } from "node:child_process";
import { readdirSync, readFileSync, existsSync, openSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const HERE = path.dirname(fileURLToPath(import.meta.url));
export const ROOT = path.resolve(HERE, "..", "..");
const PORT = 4000;
const BASE_URL = `http://localhost:${PORT}`;
const AVAILABLE_URL = `${BASE_URL}/charon/getAvailable`;

// A string literal from perf.js's timerEnd() output — present in the bundle only
// when timer calls were NOT stripped (i.e. built with --includeTiming).
const TIMER_MARKER = "ms. Average:";

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function distHasTimers() {
const distDir = path.join(ROOT, "dist");
if (!existsSync(distDir)) return false;
const jsFiles = readdirSync(distDir).filter((f) => f.endsWith(".js"));
return jsFiles.some((f) =>
readFileSync(path.join(distDir, f), "utf8").includes(TIMER_MARKER)
);
}

function runBuild() {
return new Promise((resolve, reject) => {
console.log("[build] node auspice.js build --includeTiming (production, timers on) …");
const proc = spawn("node", ["auspice.js", "build", "--includeTiming"], {
cwd: ROOT,
stdio: "inherit",
});
proc.on("exit", (code) =>
code === 0 ? resolve() : reject(new Error(`build exited with code ${code}`))
);
proc.on("error", reject);
});
}

export async function ensureTimingBuild({ force = false } = {}) {
if (!force && distHasTimers()) {
console.log("[build] reusing existing timers-enabled dist/ build");
return;
}
await runBuild();
if (!distHasTimers()) {
throw new Error(
"[build] FATAL: production build did not retain timers — check babel.config.cjs strip logic / --includeTiming"
);
}
console.log("[build] timers-enabled build confirmed in dist/");
}

async function isServerUp() {
try {
const r = await fetch(AVAILABLE_URL);
return r.ok;
} catch {
return false;
}
}

let serverProc = null;

export async function startServer() {
if (await isServerUp()) {
console.log(`[serve] reusing server already listening on ${BASE_URL}`);
return { spawned: false };
}
const logPath = path.join(HERE, ".server.log");
const out = openSync(logPath, "a");
console.log(`[serve] starting: auspice view data test/data test/fetched-jsons (log: ${logPath})`);
serverProc = spawn(
"node",
["auspice.js", "view", "test/data", "test/fetched-jsons"],
{ cwd: ROOT, stdio: ["ignore", out, out] }
);
serverProc.on("error", (e) => console.error("[serve] spawn error:", e));

const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
if (await isServerUp()) {
console.log(`[serve] up at ${BASE_URL}`);
return { spawned: true };
}
if (serverProc.exitCode !== null) {
throw new Error(`[serve] server exited early (code ${serverProc.exitCode}); see ${logPath}`);
}
await sleep(500);
}
throw new Error(`[serve] server did not become ready within 60s; see ${logPath}`);
}

export async function stopServer() {
if (serverProc && serverProc.exitCode === null) {
serverProc.kill("SIGTERM");
console.log("[serve] stopped spawned server");
}
serverProc = null;
}

export { BASE_URL };
Loading