diff --git a/.gitignore b/.gitignore index 35f0584a2..c4cd783b2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ big_flu s3/ /local_narratives/ /datasets/ +/test/profiling/baselines/ +!test/profiling/baselines/.gitkeep ### OSX ### .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index c36c97cb6..235b8e8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/package.json b/package.json index 40829db54..56f0a680e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/fetch-test-data b/scripts/fetch-test-data index 60bdba662..2082a1d22 100755 --- a/scripts/fetch-test-data +++ b/scripts/fetch-test-data @@ -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() { diff --git a/src/components/tree/phyloTree/change.ts b/src/components/tree/phyloTree/change.ts index c478e5f92..3a8769163 100644 --- a/src/components/tree/phyloTree/change.ts +++ b/src/components/tree/phyloTree/change.ts @@ -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 */ @@ -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) diff --git a/src/components/tree/phyloTree/layouts.ts b/src/components/tree/phyloTree/layouts.ts index 7b8e21169..a1537c670 100644 --- a/src/components/tree/phyloTree/layouts.ts +++ b/src/components/tree/phyloTree/layouts.ts @@ -558,6 +558,7 @@ export const mapToScreen = function mapToScreen(this: PhyloTreeType): void { if (this.params.showStreamTrees) { this.mapStreamsToScreen() } + timerEnd("mapToScreen"); }; /** diff --git a/test/profiling/.gitignore b/test/profiling/.gitignore new file mode 100644 index 000000000..382345845 --- /dev/null +++ b/test/profiling/.gitignore @@ -0,0 +1 @@ +.server.log diff --git a/test/profiling/README.md b/test/profiling/README.md new file mode 100644 index 000000000..7a9881eae --- /dev/null +++ b/test/profiling/README.md @@ -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-.json` and `baseline-latest.json` — machine-readable (per + scenario × marker × span: median/p95/count/total + raw samples). +- `report-.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. diff --git a/test/profiling/aggregate.mjs b/test/profiling/aggregate.mjs new file mode 100644 index 000000000..4c804837d --- /dev/null +++ b/test/profiling/aggregate.mjs @@ -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}`; +} diff --git a/test/profiling/baselines/.gitkeep b/test/profiling/baselines/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/test/profiling/buildAndServe.mjs b/test/profiling/buildAndServe.mjs new file mode 100644 index 000000000..129bbe13d --- /dev/null +++ b/test/profiling/buildAndServe.mjs @@ -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 }; diff --git a/test/profiling/consoleTimers.mjs b/test/profiling/consoleTimers.mjs new file mode 100644 index 000000000..a6b9e7059 --- /dev/null +++ b/test/profiling/consoleTimers.mjs @@ -0,0 +1,84 @@ +/** + * Parse and collect Auspice's `src/util/perf.js` console timer output. + * + * perf.js prints one line per timerEnd(): + * `Timer {name} (#{n}) took {X}ms. Average: {Y}ms.` + * via console.warn when the call took >20ms, else console.log. We must therefore + * accept BOTH Playwright console message types 'log' and 'warning' — dropping + * 'warning' would silently discard every slow (interesting) span. + * + * We record the per-call `took` value (per-interaction). The reported `Average` + * is a cumulative session mean held in perf.js's module-scoped `dbsingle`, so it + * is NOT per-interaction and is deliberately ignored. + */ + +export const TIMER_RE = /^Timer (.+?) \(#(\d+)\) took (\d+)ms\. Average: (\d+)ms\.$/; + +export function parseTimerLine(text) { + const m = TIMER_RE.exec(text); + if (!m) return null; + return { name: m[1], callIndex: Number(m[2]), took: Number(m[3]), avg: Number(m[4]) }; +} + +export class TimerCollector { + constructor() { + this.samples = []; // { marker, name, took, callIndex } + this.marker = null; // label of the interaction currently in flight + this._waiters = []; // one-shot resolvers keyed by span name + } + + attach(page) { + page.on("console", (msg) => { + const type = msg.type(); + if (type !== "log" && type !== "warning") return; + const parsed = parseTimerLine(msg.text()); + if (!parsed) return; + this.samples.push({ + marker: this.marker, + name: parsed.name, + took: parsed.took, + callIndex: parsed.callIndex, + }); + this._notify(parsed); + }); + } + + setMarker(marker) { + this.marker = marker; + } + + hadAnyTimer() { + return this.samples.length > 0; + } + + /** All collected samples (marker-tagged). */ + all() { + return this.samples; + } + + _notify(parsed) { + if (this._waiters.length === 0) return; + const remaining = []; + for (const w of this._waiters) { + if (w.name === parsed.name) w.resolve(true); + else remaining.push(w); + } + this._waiters = remaining; + } + + /** + * Resolve when the NEXT console line for `name` arrives (after this call), + * or `false` on timeout. Used as an interaction-settled signal, e.g. wait for + * the "phyloTree render()" or "phylotree.change()" line that ends a render. + */ + waitForSpan(name, timeoutMs) { + return new Promise((resolve) => { + const waiter = { name, resolve }; + this._waiters.push(waiter); + setTimeout(() => { + this._waiters = this._waiters.filter((w) => w !== waiter); + resolve(false); + }, timeoutMs); + }); + } +} diff --git a/test/profiling/domSnapshot.mjs b/test/profiling/domSnapshot.mjs new file mode 100644 index 000000000..2f0737064 --- /dev/null +++ b/test/profiling/domSnapshot.mjs @@ -0,0 +1,153 @@ +/** + * Capture and compare the rendered phyloTree SVG DOM. + * + * Used by renderEquivalence.mjs to assert that an incrementally-updated tree + * (phylotree.change()) is identical to a from-scratch full render of the same + * state. Captures the geometry/colour-critical, stable-id elements (tips, branch + * stems/tees, confidence lines) plus label groups, reading BOTH inline style and + * attribute (style wins, per CSS precedence) and normalizing values so cosmetic + * draw-vs-update differences (e.g. stroke-width "2" vs "2px") don't register. + */ + +/** Runs in the browser: returns a structured snapshot of the tree SVG. */ +export function snapshot(page) { + return page.evaluate(() => { + const round = (v) => { + const f = parseFloat(String(v).replace("px", "").trim()); + return Number.isNaN(f) ? String(v) : String(Math.round(f * 10) / 10); + }; + // read a property from style first (wins), else attribute + const raw = (el, name) => { + const s = el.style.getPropertyValue(name); + return s !== "" && s != null ? s : el.getAttribute(name) || ""; + }; + const prop = (el, name, isNum) => (isNum ? round(raw(el, name)) : raw(el, name)); + + const byId = (sel, spec) => { + const out = {}; + document.querySelectorAll(sel).forEach((el) => { + if (!el.id) return; + const rec = {}; + for (const [name, isNum] of spec) rec[name] = prop(el, name, isNum); + out[el.id] = rec; + }); + return out; + }; + + // label groups have no per-element id -> capture as a list keyed by content+pos + const textGroup = (sel) => + [...document.querySelectorAll(sel)].map((el) => ({ + text: el.textContent || "", + x: round(raw(el, "x")), + y: round(raw(el, "y")), + visibility: raw(el, "visibility"), + "font-size": round(raw(el, "font-size")), + })); + + return { + tips: byId("circle.tip", [ + ["cx", true], ["cy", true], ["r", true], + ["fill", false], ["stroke", false], ["visibility", false], + ]), + branchS: byId("path.branch.S", [ + ["d", false], ["stroke", false], ["stroke-width", true], ["visibility", false], + ]), + branchT: byId("path.branch.T", [ + ["d", false], ["stroke", false], ["stroke-width", true], ["visibility", false], + ]), + conf: byId("path.conf", [ + ["d", false], ["stroke", false], ["stroke-width", true], + ]), + tipLabels: textGroup("#tipLabels text.tipLabel"), + branchLabels: textGroup("#branchLabels text.branchLabel"), + }; + }); +} + +/** Known draw-vs-update inconsistencies to ignore (documented, not regressions). */ +const ALLOWLIST = { + // updateBranchLabels omits text-anchor/fill/font-family and hardcodes x=xTip-5 + // (drawBranchLabels is orientation-aware) — a real draw/update divergence tracked + // separately, not something an incremental *tree* op regresses. + branchLabels: new Set(["x"]), +}; + +const KEYED = ["tips", "branchS", "branchT", "conf"]; + +/** Compare two id-keyed element maps; returns mismatch records. */ +function diffKeyed(cls, ref, incr) { + const mismatches = []; + const only = { ref: 0, incr: 0 }; + const allow = ALLOWLIST[cls] || new Set(); + const ids = new Set([...Object.keys(ref), ...Object.keys(incr)]); + for (const id of ids) { + const a = ref[id], b = incr[id]; + if (!a) { only.incr++; continue; } + if (!b) { only.ref++; continue; } + for (const k of Object.keys(a)) { + if (allow.has(k)) continue; + if (a[k] !== b[k]) mismatches.push({ cls, id, prop: k, ref: a[k], incr: b[k] }); + } + } + return { mismatches, only }; +} + +/** Compare label lists (order-independent) after allowlisting props. */ +function diffLabels(cls, ref, incr) { + const allow = ALLOWLIST[cls] || new Set(); + const key = (r) => JSON.stringify(Object.fromEntries(Object.entries(r).filter(([k]) => !allow.has(k)))); + const rc = ref.map(key).sort(), ic = incr.map(key).sort(); + const mismatches = []; + if (rc.length !== ic.length) mismatches.push({ cls, id: "(count)", prop: "length", ref: rc.length, incr: ic.length }); + for (let i = 0; i < Math.max(rc.length, ic.length); i++) { + if (rc[i] !== ic[i]) { mismatches.push({ cls, id: `[${i}]`, prop: "record", ref: rc[i], incr: ic[i] }); break; } + } + return { mismatches, only: { ref: 0, incr: 0 } }; +} + +/** Full comparison of two snapshots. Returns {total, byClass, examples}. */ +export function compare(ref, incr) { + const byClass = {}; + const examples = []; + let total = 0; + for (const cls of KEYED) { + const { mismatches, only } = diffKeyed(cls, ref[cls], incr[cls]); + byClass[cls] = { diffs: mismatches.length, onlyRef: only.ref, onlyIncr: only.incr }; + total += mismatches.length + only.ref + only.incr; + for (const m of mismatches.slice(0, 4)) examples.push(m); + } + for (const cls of ["tipLabels", "branchLabels"]) { + const { mismatches } = diffLabels(cls, ref[cls], incr[cls]); + byClass[cls] = { diffs: mismatches.length }; + total += mismatches.length; + for (const m of mismatches.slice(0, 2)) examples.push(m); + } + return { total, byClass, examples: examples.slice(0, 8) }; +} + +/** Count how many element records changed between two snapshots (for the "op did something" guard). */ +export function countChanged(a, b) { + let n = 0; + for (const cls of KEYED) { + const ids = new Set([...Object.keys(a[cls]), ...Object.keys(b[cls])]); + for (const id of ids) { + const x = a[cls][id], y = b[cls][id]; + if (!x || !y) { n++; continue; } + if (Object.keys(x).some((k) => x[k] !== y[k])) n++; + } + } + return n; +} + +/** A cheap signature for settle-detection (do positions/paths still change?). */ +export function signature(page) { + return page.evaluate(() => { + const t = [...document.querySelectorAll("circle.tip")].slice(0, 50) + .map((e) => (e.getAttribute("cx") || "") + (e.style.visibility || "")).join(","); + const b = [...document.querySelectorAll("path.branch.S")].slice(0, 25) + .map((e) => e.getAttribute("d") || "").join("|"); + const counts = ["circle.tip", "path.branch.S", "path.branch.T", "path.conf"] + .map((s) => document.querySelectorAll(s).length).join("/"); + return counts + "##" + t + "##" + b; + }); +} diff --git a/test/profiling/driveScenario.mjs b/test/profiling/driveScenario.mjs new file mode 100644 index 000000000..42fc0c043 --- /dev/null +++ b/test/profiling/driveScenario.mjs @@ -0,0 +1,88 @@ +/** + * Run a single trial of one scenario in an isolated browser context. + * + * Isolation: a fresh BrowserContext per trial resets perf.js's module-scoped + * `dbsingle` accumulator, so timer samples never bleed across scenarios/trials. + * Every captured line is tagged with the active interaction marker. + */ + +import { scenarioUrl } from "./scenarios.mjs"; +import { TimerCollector } from "./consoleTimers.mjs"; +import { BASE_URL } from "./buildAndServe.mjs"; + +const RENDER_SPAN = "phyloTree render()"; +const CHANGE_SPAN = "phylotree.change()"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export async function runScenarioTrial(browser, scenario, { throttle = 1, trial = 0 } = {}) { + const context = await browser.newContext(); + const page = await context.newPage(); + const collector = new TimerCollector(); + collector.attach(page); + page.on("pageerror", (e) => console.error(` [pageerror ${scenario.id}] ${e.message}`)); + + if (throttle && throttle !== 1) { + const cdp = await context.newCDPSession(page); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: throttle }); + } + + const url = BASE_URL + scenarioUrl(scenario); + const loadTimeout = scenario.loadTimeoutMs || 60000; + const settle = scenario.settleAfterMs ?? 800; + + // --- initial load: wait for the render-complete timer line, not networkidle --- + collector.setMarker("load"); + const rendered = collector.waitForSpan(RENDER_SPAN, loadTimeout); + await page.goto(url, { waitUntil: "commit", timeout: loadTimeout }); + const renderOk = await rendered; + if (!renderOk) { + try { + await page.locator("svg#MainTree circle.tip").first().waitFor({ timeout: 15000 }); + } catch { + console.warn(` [warn ${scenario.id}] no '${RENDER_SPAN}' line and no tips within timeout`); + } + } + await sleep(settle); + + // --- animation window (URL-driven auto-start): capture ticks + cascade --- + if (scenario.animateMs) { + collector.setMarker("animation"); + await sleep(scenario.animateMs); + } + + // --- best-effort DOM interaction steps --- + for (const step of scenario.steps || []) { + collector.setMarker(step.label); + try { + await runStep(page, collector, step); + } catch (e) { + console.warn(` [skip ${scenario.id}/${step.label}] ${e.message || e}`); + collector.samples.push({ marker: step.label, name: "__step_skipped__", took: 0 }); + } + await sleep(settle); + } + + collector.setMarker(null); + const hadTimers = collector.hadAnyTimer(); + const samples = collector.all().map((s) => ({ ...s, scenario: scenario.id, trial })); + await context.close(); + return { samples, hadTimers, renderOk }; +} + +async function runStep(page, collector, step) { + if (step.kind === "wait") { + await sleep(step.waitMs || 500); + return; + } + const changed = collector.waitForSpan(CHANGE_SPAN, 30000); + if (step.kind === "layout") { + await page.getByRole("button", { name: step.value, exact: true }).click({ timeout: 8000 }); + } else if (step.kind === "colorby") { + await page.locator("#selectColorBy").click({ timeout: 8000 }); + await page.keyboard.type(step.value, { delay: 20 }); + await page.keyboard.press("Enter"); + } else { + throw new Error(`unknown step kind '${step.kind}'`); + } + await changed; +} diff --git a/test/profiling/renderEquivalence.mjs b/test/profiling/renderEquivalence.mjs new file mode 100644 index 000000000..4d1b5cd8a --- /dev/null +++ b/test/profiling/renderEquivalence.mjs @@ -0,0 +1,178 @@ +/** + * Render-equivalence regression suite. + * + * For each operation (colorBy, layout, distance, filter, zoom, …) and for + * sequences of operations, this drives the INCREMENTAL update path in-app and + * asserts the settled SVG DOM is identical to a from-scratch FULL render of the + * same end state. A mismatch = a stale-DOM regression in the incremental path. + * + * Driving: history.pushState(newURL) + a popstate event triggers the app's own + * listener (monitor.js) which takes the incremental branch (navigation.js Case 1, + * createStateFromQueryOrJSONs) — the same phylotree.change() path a control uses, + * with zero source changes. Reference: a fresh page at the app's resulting URL. + * + * npm run render-equiv (reuses the current dist/ build) + * npm run render-equiv -- --build (force a fresh build first) + * npm run render-equiv -- --only ebola-colorby-categorical,ebola-zoom + */ + +import { chromium } from "playwright"; +import { ensureTimingBuild, startServer, stopServer, BASE_URL } from "./buildAndServe.mjs"; +import { snapshot, compare, countChanged, signature } from "./domSnapshot.mjs"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Build a query string, supporting valueless params (value === true -> `key`). */ +function buildQuery(params) { + const parts = []; + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === false) continue; + parts.push(v === true ? encodeURIComponent(k) : `${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + } + return parts.join("&"); +} + +async function settle(page, { min = 700, max = 30000, interval = 250 } = {}) { + await sleep(min); + let prev = await signature(page); + let stable = 0; + const deadline = Date.now() + max; + while (Date.now() < deadline) { + await sleep(interval); + const cur = await signature(page); + if (cur === prev) { if (++stable >= 2) return; } else stable = 0; + prev = cur; + } +} + +async function loadFull(page, url, loadTimeout) { + await page.goto(url, { waitUntil: "commit", timeout: loadTimeout }); + await page.locator("circle.tip").first().waitFor({ state: "attached", timeout: loadTimeout }); + await settle(page, { max: loadTimeout }); +} + +async function driveIncremental(page, path, loadTimeout) { + await page.evaluate((p) => { + history.pushState({}, "", p); + window.dispatchEvent(new PopStateEvent("popstate")); + }, path); + await settle(page, { max: loadTimeout }); +} + +// --- Scenarios ------------------------------------------------------------- +// dataset: URL path segment. base: initial query params. steps: each merges +// `set` into the running params and drives one incremental op. +const SCENARIOS = [ + // ---- ebola: single operations ---- + { id: "ebola-colorby-categorical", dataset: "ebola-ebov-2013", base: { d: "tree", c: "country" }, steps: [{ set: { c: "division" } }] }, + { id: "ebola-colorby-continuous", dataset: "ebola-ebov-2013", base: { d: "tree", c: "country" }, steps: [{ set: { c: "num_date" } }] }, + { id: "ebola-layout-radial", dataset: "ebola-ebov-2013", base: { d: "tree", l: "rect" }, steps: [{ set: { l: "radial" } }] }, + { id: "ebola-layout-unrooted", dataset: "ebola-ebov-2013", base: { d: "tree", l: "rect" }, steps: [{ set: { l: "unrooted" } }] }, + { id: "ebola-layout-clock", dataset: "ebola-ebov-2013", base: { d: "tree", l: "rect" }, steps: [{ set: { l: "clock" } }] }, + { id: "ebola-layout-scatter", dataset: "ebola-ebov-2013", base: { d: "tree", l: "rect" }, steps: [{ set: { l: "scatter" } }] }, + { id: "ebola-distance", dataset: "ebola-ebov-2013", base: { d: "tree", m: "num_date" }, steps: [{ set: { m: "div" } }] }, + { id: "ebola-datefilter", dataset: "ebola-ebov-2013", base: { d: "tree" }, steps: [{ set: { dmin: "2014-08-01", dmax: "2015-03-01" } }] }, + { id: "ebola-traitfilter", dataset: "ebola-ebov-2013", base: { d: "tree" }, steps: [{ set: { f_country: "Sierra Leone" } }] }, + { id: "ebola-zoom", dataset: "ebola-ebov-2013", base: { d: "tree" }, steps: [{ set: { f_country: "Sierra Leone", treeZoom: "selected" } }] }, + { id: "ebola-confidence", dataset: "ebola-ebov-2013", base: { d: "tree", m: "num_date" }, steps: [{ set: { ci: true } }] }, + + // ---- ebola: combinations (sequenced incremental ops) ---- + { id: "ebola-filter-then-colorby", dataset: "ebola-ebov-2013", base: { d: "tree", c: "country" }, steps: [{ set: { f_country: "Sierra Leone" } }, { set: { c: "division" } }] }, + { id: "ebola-colorby-then-zoom", dataset: "ebola-ebov-2013", base: { d: "tree", c: "country" }, steps: [{ set: { c: "division" } }, { set: { f_country: "Sierra Leone", treeZoom: "selected" } }] }, + { id: "ebola-date-then-layout", dataset: "ebola-ebov-2013", base: { d: "tree" }, steps: [{ set: { dmin: "2014-08-01", dmax: "2015-03-01" } }, { set: { l: "radial" } }] }, + { id: "ebola-filter-date-colorby", dataset: "ebola-ebov-2013", base: { d: "tree", c: "country" }, steps: [{ set: { f_country: "Sierra Leone" } }, { set: { dmin: "2014-08-01", dmax: "2015-03-01" } }, { set: { c: "division" } }] }, + + // ---- zika: variety ---- + { id: "zika-colorby", dataset: "zika", base: { d: "tree", c: "country" }, steps: [{ set: { c: "region" } }] }, + { id: "zika-layout-unrooted", dataset: "zika", base: { d: "tree", l: "rect" }, steps: [{ set: { l: "unrooted" } }] }, + + // ---- spike-sm: scale + streamtrees (slow) ---- + { id: "spike-colorby", dataset: "spike-sm", base: { d: "tree", c: "clade_membership" }, steps: [{ set: { c: "region" } }], slow: true }, + { id: "spike-datefilter", dataset: "spike-sm", base: { d: "tree" }, steps: [{ set: { dmin: "2021-01-01", dmax: "2021-09-01" } }], slow: true }, +]; + +async function runScenario(browser, scn) { + const loadTimeout = scn.slow ? 180000 : 45000; + const ctxIncr = await browser.newContext(); + const incrPage = await ctxIncr.newPage(); + const errors = []; + incrPage.on("pageerror", (e) => errors.push(e.message)); + + let params = { ...scn.base }; + await loadFull(incrPage, `${BASE_URL}/${scn.dataset}?${buildQuery(params)}`, loadTimeout); + const base = await snapshot(incrPage); + + for (const step of scn.steps) { + params = { ...params, ...step.set }; + await driveIncremental(incrPage, `/${scn.dataset}?${buildQuery(params)}`, loadTimeout); + } + const incr = await snapshot(incrPage); + const synced = new URL(incrPage.url()).pathname + new URL(incrPage.url()).search; + await ctxIncr.close(); + + const ctxRef = await browser.newContext(); + const refPage = await ctxRef.newPage(); + refPage.on("pageerror", (e) => errors.push(e.message)); + await loadFull(refPage, `${BASE_URL}${synced}`, loadTimeout); + const ref = await snapshot(refPage); + await ctxRef.close(); + + const result = compare(ref, incr); + const changed = countChanged(base, incr); + const pass = result.total === 0; + return { pass, changed, result, synced, errors }; +} + +function fmt(scn, r) { + const status = r.pass ? "PASS" : "FAIL"; + const counts = Object.entries(r.result.byClass) + .filter(([, v]) => v.diffs || v.onlyRef || v.onlyIncr) + .map(([k, v]) => `${k}:${v.diffs}${v.onlyRef || v.onlyIncr ? `(+/-${v.onlyRef}/${v.onlyIncr})` : ""}`) + .join(" "); + let line = ` [${status}] ${scn.id.padEnd(30)} mismatch=${r.result.total} changed=${r.changed}${counts ? " " + counts : ""}`; + if (r.changed === 0) line += " ⚠ op produced no DOM change (drive may have no-oped)"; + if (r.errors.length) line += ` ⚠ ${r.errors.length} pageerror`; + return line; +} + +async function main() { + const argv = process.argv.slice(2); + const build = argv.includes("--build"); + const onlyIdx = argv.indexOf("--only"); + const only = onlyIdx >= 0 ? argv[onlyIdx + 1].split(",") : null; + const scenarios = only ? SCENARIOS.filter((s) => only.includes(s.id)) : SCENARIOS; + + await ensureTimingBuild({ force: build }); + await startServer(); + const browser = await chromium.launch({ headless: true }); + const failures = []; + try { + for (const scn of scenarios) { + let r; + try { + r = await runScenario(browser, scn); + } catch (e) { + console.log(` [ERROR] ${scn.id}: ${e.message}`); + failures.push(scn.id); + continue; + } + console.log(fmt(scn, r)); + if (!r.pass) { + failures.push(scn.id); + for (const ex of r.result.examples) { + console.log(` ${ex.cls} ${ex.id} ${ex.prop}: ref=${JSON.stringify(ex.ref)} incr=${JSON.stringify(ex.incr)}`); + } + } + } + } finally { + await browser.close(); + await stopServer(); + } + console.log(`\n${scenarios.length - failures.length}/${scenarios.length} scenarios passed.`); + if (failures.length) { + console.log(`FAILED: ${failures.join(", ")}`); + process.exitCode = 1; + } +} + +main().catch((e) => { console.error("[FATAL]", e); stopServer().finally(() => process.exit(1)); }); diff --git a/test/profiling/report.mjs b/test/profiling/report.mjs new file mode 100644 index 000000000..fcffc34e3 --- /dev/null +++ b/test/profiling/report.mjs @@ -0,0 +1,115 @@ +/** + * Write the machine-readable baseline JSON and the human-readable ranked report. + * Both support before/after diffing against a prior baseline (measure-only — + * no code change is required to compare two runs). + */ + +import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { rowKey } from "./aggregate.mjs"; + +export function writeBaselineJson(rows, meta, outDir) { + mkdirSync(outDir, { recursive: true }); + const payload = { meta, rows }; + const shaName = `baseline-${meta.gitSha || "unknown"}.json`; + const shaPath = path.join(outDir, shaName); + const latestPath = path.join(outDir, "baseline-latest.json"); + const json = JSON.stringify(payload, null, 2); + writeFileSync(shaPath, json); + writeFileSync(latestPath, json); + return { shaPath, latestPath }; +} + +export function loadBaseline(file) { + if (!file || !existsSync(file)) return null; + try { + const parsed = JSON.parse(readFileSync(file, "utf8")); + const map = new Map(); + for (const r of parsed.rows || []) map.set(rowKey(r), r); + return { meta: parsed.meta, map }; + } catch { + return null; + } +} + +const pad = (s, n) => String(s).padEnd(n); +const padL = (s, n) => String(s).padStart(n); +const ms = (v) => `${Math.round(v)}`; + +function deltaCell(cur, base) { + if (!base) return "—"; + const d = cur - base.median; + const pct = base.median ? (d / base.median) * 100 : 0; + const sign = d > 0 ? "+" : ""; + return `${sign}${ms(d)} (${sign}${pct.toFixed(0)}%)`; +} + +function table(rows, baseline) { + const cols = [ + ["span", 26], + ["scenario", 22], + ["marker", 18], + ["median", 8], + ["p95", 7], + ["max", 7], + ["n", 4], + ]; + if (baseline) cols.push(["Δ median", 16]); + const header = cols.map(([h, w]) => (h === "median" || h === "p95" || h === "max" || h === "n" ? padL(h, w) : pad(h, w))).join(" "); + const lines = [header, "-".repeat(header.length)]; + for (const r of rows) { + const base = baseline ? baseline.map.get(rowKey(r)) : null; + const cells = [ + pad(r.name, 26), + pad(r.scenario, 22), + pad(r.marker, 18), + padL(ms(r.median), 8), + padL(ms(r.p95), 7), + padL(ms(r.max), 7), + padL(r.count, 4), + ]; + if (baseline) cells.push(pad(deltaCell(r.median, base), 16)); + lines.push(cells.join(" ")); + } + return lines.join("\n"); +} + +export function buildRankedReport(rows, meta, baseline) { + const byMedian = [...rows].sort((a, b) => b.median - a.median); + const byTotal = [...rows].sort((a, b) => b.total - a.total); + + const out = []; + out.push(`# Auspice profiling baseline`); + out.push(""); + out.push(`- date: ${meta.date}`); + out.push(`- git: ${meta.gitSha} (${meta.gitBranch})`); + out.push(`- node: ${meta.node} cpuThrottle: ${meta.cpuThrottle}x`); + out.push(`- scenarios: ${meta.scenarioIds.join(", ")}`); + if (baseline) out.push(`- Δ vs baseline: ${baseline.meta?.gitSha} @ ${baseline.meta?.date}`); + out.push(""); + out.push(`## Top 15 hotspots by per-call median (ms)`); + out.push(""); + out.push("```"); + out.push(table(byMedian.slice(0, 15), baseline)); + out.push("```"); + out.push(""); + out.push(`## Top 15 by total time across all calls (ms)`); + out.push(""); + out.push("```"); + out.push(table(byTotal.slice(0, 15), baseline)); + out.push("```"); + out.push(""); + out.push(`## All spans (by median)`); + out.push(""); + out.push("```"); + out.push(table(byMedian, baseline)); + out.push("```"); + out.push(""); + return out.join("\n"); +} + +export function writeRankedMarkdown(rows, meta, outPath, baseline) { + const md = buildRankedReport(rows, meta, baseline); + writeFileSync(outPath, md); + return md; +} diff --git a/test/profiling/runProfiling.mjs b/test/profiling/runProfiling.mjs new file mode 100644 index 000000000..a1075bd23 --- /dev/null +++ b/test/profiling/runProfiling.mjs @@ -0,0 +1,116 @@ +/** + * Auspice profiling harness — entrypoint (MEASURE-ONLY). + * + * Ensures a --includeTiming production build, serves the local data/ dir, drives + * a set of scenarios headless while capturing perf.js console timers, and writes + * a baseline JSON + a ranked markdown report. + * + * node test/profiling/runProfiling.mjs [flags] + * --build force a fresh --includeTiming production build + * --throttle N CPU throttle rate (default 1 = off; e.g. 4 = 4x slower) + * --only a,b,c run only these scenario ids + * --baseline diff results against a prior baseline JSON + * --out output dir (default test/profiling/baselines) + */ + +import { chromium } from "playwright"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureTimingBuild, startServer, stopServer, ROOT } from "./buildAndServe.mjs"; +import { scenarios as ALL_SCENARIOS } from "./scenarios.mjs"; +import { runScenarioTrial } from "./driveScenario.mjs"; +import { aggregate } from "./aggregate.mjs"; +import { writeBaselineJson, writeRankedMarkdown, loadBaseline } from "./report.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +function parseFlags(argv) { + const flags = { build: false, throttle: 1, only: null, baseline: null, out: path.join(HERE, "baselines") }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--build") flags.build = true; + else if (a === "--throttle") flags.throttle = Number(argv[++i]); + else if (a === "--only") flags.only = argv[++i].split(",").map((s) => s.trim()); + else if (a === "--baseline") flags.baseline = argv[++i]; + else if (a === "--out") flags.out = path.resolve(argv[++i]); + } + return flags; +} + +function gitInfo() { + const run = (cmd) => { + try { return execSync(cmd, { cwd: ROOT }).toString().trim(); } catch { return "unknown"; } + }; + return { gitSha: run("git rev-parse --short HEAD"), gitBranch: run("git rev-parse --abbrev-ref HEAD") }; +} + +async function main() { + const flags = parseFlags(process.argv.slice(2)); + const scenarioList = flags.only + ? ALL_SCENARIOS.filter((s) => flags.only.includes(s.id)) + : ALL_SCENARIOS; + if (scenarioList.length === 0) { + throw new Error(`no scenarios matched --only ${flags.only}`); + } + + await ensureTimingBuild({ force: flags.build }); + await startServer(); + + const browser = await chromium.launch({ headless: true }); + const allSamples = []; + let sawAnyTimer = false; + + try { + for (const scenario of scenarioList) { + const total = (scenario.warmup || 0) + (scenario.repeat || 1); + console.log(`\n[scenario] ${scenario.id} (${scenario.warmup || 0} warmup + ${scenario.repeat || 1} measured)`); + for (let t = 0; t < total; t++) { + const isWarmup = t < (scenario.warmup || 0); + const { samples, hadTimers, renderOk } = await runScenarioTrial(browser, scenario, { + throttle: flags.throttle, + trial: t, + }); + sawAnyTimer = sawAnyTimer || hadTimers; + const tag = isWarmup ? "warmup" : "measured"; + console.log(` trial ${t} (${tag}): ${samples.filter((s) => !s.name.startsWith("__")).length} timer samples, renderOk=${renderOk}`); + if (!isWarmup) allSamples.push(...samples); + } + + // Global guard: after the first (fast) scenario, fail loudly if no timers + // were seen — means the served build has timers stripped. + if (scenario === scenarioList[0] && !sawAnyTimer) { + throw new Error( + "No 'Timer …' console lines captured on the first scenario. The served dist/ build likely has timers stripped — rerun with --build." + ); + } + } + } finally { + await browser.close(); + await stopServer(); + } + + const rows = aggregate(allSamples); + const meta = { + date: new Date().toISOString(), + node: process.version, + cpuThrottle: flags.throttle, + scenarioIds: scenarioList.map((s) => s.id), + ...gitInfo(), + }; + + const baseline = loadBaseline(flags.baseline); + const { shaPath, latestPath } = writeBaselineJson(rows, meta, flags.out); + const mdPath = path.join(flags.out, `report-${meta.gitSha}.md`); + const md = writeRankedMarkdown(rows, meta, mdPath, baseline); + + console.log("\n" + md); + console.log(`\n[done] baseline: ${shaPath}`); + console.log(`[done] latest: ${latestPath}`); + console.log(`[done] report: ${mdPath}`); +} + +main().catch((e) => { + console.error("\n[FATAL]", e); + stopServer().finally(() => process.exit(1)); +}); diff --git a/test/profiling/scenarios.mjs b/test/profiling/scenarios.mjs new file mode 100644 index 000000000..dd9a74e90 --- /dev/null +++ b/test/profiling/scenarios.mjs @@ -0,0 +1,126 @@ +/** + * Declarative profiling scenarios. + * + * Design note: most instrumented spans are best measured via URL-driven page + * loads — a load with `?l=radial` runs exactly the same setLayout/mapToScreen + * code as an in-app layout change, and a load with `?c=region` runs the same + * changeColorBy calculation. The one path that only exists in-app (the + * incremental `phylotree.change()` cascade) is captured robustly by the + * `animate` scenario, whose every tick dispatches a date-filter change. DOM-click + * steps (colorby/layout) are included as best-effort extras for true transition + * numbers and degrade gracefully if a selector can't be driven. + * + * Scenario schema: + * { id, dataset, params, steps, repeat, warmup, loadTimeoutMs, settleAfterMs, animateMs } + * Step schema: + * { label, kind: 'colorby'|'layout'|'wait', value?, waitMs? } + */ + +const SPIKE = "/spike-sm"; + +export const scenarios = [ + // --- small/medium loads: fast sanity + the timers-present guard (zika is first) --- + { + id: "zika-load", + dataset: "/zika", + params: {}, + steps: [], + repeat: 5, + warmup: 1, + loadTimeoutMs: 30000, + settleAfterMs: 800, + }, + { + id: "ebola-load", + dataset: "/ebola-ebov-2013", + params: { d: "tree,map,entropy" }, + steps: [], + repeat: 5, + warmup: 1, + loadTimeoutMs: 45000, + settleAfterMs: 1000, + }, + + // --- STRESS: 35k-tip spike, all panels — every load-time span --- + { + id: "spike-load-all-panels", + dataset: SPIKE, + params: { d: "tree,map,entropy,frequencies" }, + steps: [], + repeat: 2, + warmup: 1, + loadTimeoutMs: 180000, + settleAfterMs: 2500, // catch debounced updateFrequencyData + map + entropy + }, + + // --- per-layout compute cost on the big tree (tree-only to isolate + cut load) --- + { + id: "spike-load-radial", + dataset: SPIKE, + params: { d: "tree", l: "radial" }, + steps: [], + repeat: 2, + warmup: 1, + loadTimeoutMs: 180000, + settleAfterMs: 1500, + }, + { + id: "spike-load-unrooted", + dataset: SPIKE, + params: { d: "tree", l: "unrooted" }, + steps: [], + repeat: 2, + warmup: 1, + loadTimeoutMs: 180000, + settleAfterMs: 1500, + }, + + // --- incremental cascade + per-tick cost (URL-driven, robust) --- + // animate = start,end,loop,cumulative,speedMs + { + id: "spike-animation", + dataset: SPIKE, + params: { d: "tree", animate: "2020-03-01,2022-06-01,0,0,3000" }, + steps: [], + repeat: 2, + warmup: 0, + loadTimeoutMs: 180000, + settleAfterMs: 500, + animateMs: 10000, // capture animation ticks for this wall-clock window + }, + + // --- best-effort DOM incremental transitions (skipped gracefully on failure) --- + { + id: "spike-colorby", + dataset: SPIKE, + params: { d: "tree,entropy" }, + steps: [ + { label: "colorby-region", kind: "colorby", value: "region" }, + { label: "colorby-country", kind: "colorby", value: "country" }, + ], + repeat: 2, + warmup: 0, + loadTimeoutMs: 180000, + settleAfterMs: 1200, + }, + { + id: "spike-layout", + dataset: SPIKE, + params: { d: "tree" }, + steps: [ + { label: "layout-radial", kind: "layout", value: "radial" }, + { label: "layout-unrooted", kind: "layout", value: "unrooted" }, + { label: "layout-rect", kind: "layout", value: "rectangular" }, + ], + repeat: 2, + warmup: 0, + loadTimeoutMs: 180000, + settleAfterMs: 1200, + }, +]; + +/** Build a dataset URL path + query string from a scenario. */ +export function scenarioUrl(scenario) { + const qs = new URLSearchParams(scenario.params || {}).toString(); + return qs ? `${scenario.dataset}?${qs}` : scenario.dataset; +}