Performance Testing
SkillDev toolsUse when adding, modifying, or debugging Handsontable performance test scenarios in performance-tests/ - covers the CDP trace-based measurement system, scenario structure (fixture + config + spec), trace-parser integration, hook timing for filtering/sorting, golden snapshot workflow on GitHub Pages, and the CI comparison pipeline. Trigger whenever work touches performance-tests/ files, when asked to benchmark a Handsontable feature, or when adding a new performance scenario.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Performance Testing skill
What this skill tells your AI
The instructions your AI receives, as published by handsontable/handsontable in .claude/skills/performance-testing/SKILL.md and read by ahel’s review.
Overview
The performance-tests/ package measures Handsontable rendering and interaction performance using Playwright + Chrome DevTools Protocol (CDP) traces. It is a standalone package outside the pnpm workspace -- it has its own package.json, node_modules, and ESLint config.
Each scenario traces a specific user interaction (scrolling, filtering, sorting, editing), parses the CDP trace into DevTools-equivalent categories (scripting, rendering, painting, idle), and produces a compact markdown PR comment plus a self-contained interactive HTML report comparing against a golden baseline from develop.
Package Structure
performance-tests/
scripts/run.mjs # Orchestrator: build HOT UMD -> copy to fixtures -> run Playwright
scripts/replay-goldens.mjs # Replays gh-pages develop goldens to re-derive the callout thresholds
playwright.config.ts # Sequential, 1 worker, 5 min timeout, chromium only
trace-parser.mjs # CDP trace -> DevTools-equivalent category breakdown
.eslintrc.js # Extends root config, relaxes JSDoc/console/await-in-loop rules
lib/
setup.mjs # Playwright globalSetup: records Chromium build + machine to output/environment.json
environment.mjs # Run provenance and the baseline compatibility key (Chromium, harness version)
trace-runner.mjs # CDP Tracing.start/stop + warmup/iteration loop + progress output; owns HARNESS_VERSION
hook-timing.mjs # Hook pair timing (inject/get/save) for before/after measurements
snapshot-store.mjs # Golden baseline save/load; refuses an incompatible baseline and says why
median-snapshot.mjs # Synthesizes the rolling-median develop baseline (+ its run-to-run spread)
thresholds.mjs # Shared classification logic (regression/improvement thresholds)
chart-generator.mjs # Inline SVG horizontal bar charts (base64 data URIs)
report-builder.mjs # Compact markdown PR comment (summary table + regression callouts)
html-report-builder.mjs # Self-contained interactive HTML report (inline CSS + JS)
build-history-index.mjs # Generates gh-pages history listing for develop runs
teardown.mjs # Playwright globalTeardown: parse traces -> report
fs-utils.mjs # Shared filesystem helpers (exists)
scroll-utils.mjs # Scroll-and-wait helpers (scrollToRow, scrollToColumn)
scenarios/
<name>/
scenario.config.mjs # { name, warmupRuns, iterations }
fixture.html # Standalone HTML loading HOT UMD from ../../fixtures/
<name>.spec.ts # Playwright test using runTracedScenario()
fixtures/
.gitkeep # Built JS/CSS copied here by run.mjs (gitignored)
golden/ # Golden snapshots (gitignored, fetched from gh-pages branch)
output/ # Trace JSONs + result.md + report.html (gitignored)
Adding a New Scenario
Every scenario needs exactly three files in scenarios/<name>/:
1. scenario.config.mjs
// Grid: <rows> x <cols> -- <rationale for this grid size>
export default {
name: 'my-scenario',
warmupRuns: 1,
iterations: 3,
// Bump when this spec changes what the marked window contains, or when `iterations` changes: the
// median baseline only draws on develop goldens recorded at the same version (see
// lib/environment.mjs).
measurementVersion: 1,
};
measurementVersion covers everything scenario-local that changes the published number or its spread: the window's contents, and the iteration count (a mean of five and a mean of three have the same expected value but different spreads, and CV run would mean something different per row). HARNESS_VERSION covers runner-wide changes. When both change in one PR, the harness bump already restarts the whole golden pool and the scenario bump is not needed on top; say so in the config comment.
The name must match the directory name -- it determines the output/<name>/ subdirectory, and the teardown reads measurementVersion from the config by that name. Add a comment documenting the grid size and why it was chosen.
2. fixture.html
A standalone HTML file that creates a Handsontable instance and exposes it as window.__hot:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>My Scenario</title>
<link rel="stylesheet" href="../../fixtures/handsontable.css">
<script src="../../fixtures/handsontable.full.js"></script>
</head>
<body>
<div id="hot"></div>
<script>
const hot = new Handsontable(document.getElementById('hot'), {
data: Handsontable.helper.createSpreadsheetData(5000, 10),
rowHeaders: true,
colHeaders: true,
width: 1280,
height: 600,
autoRowSize: false,
autoColumnSize: false,
licenseKey: 'non-commercial-and-evaluation',
// Add scenario-specific options (filters, columnSorting, etc.)
});
window.__hot = hot;
</script>
</body>
</html>
Important rules for fixtures:
- Always set
autoRowSize: falseandautoColumnSize: false-- these async plugins interfere with measurements. - CSS path is
../../fixtures/handsontable.css(nothandsontable.full.css). - JS path is
../../fixtures/handsontable.full.js. - Always expose the instance as
window.__hot.
3. Spec file (<name>.spec.ts)
import { test } from '@playwright/test';
import path from 'node:path';
import { runTracedScenario } from '../../lib/trace-runner.mjs';
import config from './scenario.config.mjs';
const fixturePath = path.resolve(import.meta.dirname, 'fixture.html');
test(config.name, async({ page }) => {
await page.goto(`file://${fixturePath}`);
await page.waitForFunction(() => (window as any).__hot, undefined, { polling: 100 });
// Optional pre-trace setup (e.g., scroll to position)
await runTracedScenario({
page,
warmupRuns: config.warmupRuns,
iterations: config.iterations,
outputDir: path.resolve('output', config.name),
actionFn: async() => {
// The measured action goes here
},
// Optional: resetFn to restore state between iterations
});
});
The runTracedScenario function handles:
- Creating the output directory
- Running warmup iterations (no tracing) with progress output
- Running measured iterations with CDP tracing and heartbeat dots
- Writing trace JSON files as
iteration-{n}.json
Adding hook timing (filtering/sorting scenarios)
For scenarios that measure a specific hook pair (e.g., beforeFilter -> afterFilter):
import { injectHookTimer, getHookTiming, saveHookTimings } from '../../lib/hook-timing.mjs';
// Before tracing, inject the timer:
await injectHookTimer(page, 'beforeFilter', 'afterFilter');
const hookDeltas: number[] = [];
const outputDir = path.resolve('output', config.name);
// In afterActionFn -- NOT actionFn. getHookTiming is a page.evaluate, and inside
// actionFn its CDP round trip falls inside the marked window, so it is measured as
// part of the operation on measured iterations only:
afterActionFn: async() => {
const timing = await getHookTiming(page, 'beforeFilter', 'afterFilter');
if (timing.deltaMs != null) {
hookDeltas.push(timing.deltaMs);
}
},
// Inside resetFn, call injectHookTimer again to reset the store
// (it is idempotent -- prevents duplicate listener registration):
await injectHookTimer(page, 'beforeFilter', 'afterFilter');
// After runTracedScenario, save hook timing:
await saveHookTimings(outputDir, hookDeltas);
The saveHookTimings helper computes the average and writes hook-timing.json. The teardown automatically picks it up from each scenario directory.
Using scroll helpers
For scenarios that need to pre-scroll the grid (e.g., scroll-up starts from the bottom):
import { scrollToRow, scrollToColumn } from '../../lib/scroll-utils.mjs';
// Scroll to row 4999 and wait until it's rendered (no arbitrary timeouts)
await scrollToRow(page, 4999);
// Or scroll to column 4999
await scrollToColumn(page, 4999);
These combine scrollViewportTo() with a deterministic waitForFunction that checks the index mapper -- always use them instead of waitForTimeout.
Existing Scenarios
| Scenario | Grid size | Action | Special |
|---|---|---|---|
| scroll-down | 10000x50 | mouse.wheel(0, 350) x 500 | - |
| scroll-up | 10000x50 | mouse.wheel(0, -350) x 500 | Pre-scrolls to bottom via scrollToRow |
| scroll-right | 10x5000 | mouse.wheel(350, 0) x 500 | - |
| scroll-left | 10x5000 | mouse.wheel(-350, 0) x 500 | Pre-scrolls to right via scrollToColumn |
| filtering | 100000x100 | filters.addCondition + filter() | Hook timing |
| sorting | 100000x100 | columnSorting.sort() asc/desc alternating | Hook timing |
| cell-editing | 5000x10 | selectCell + Enter + type + Enter x 20 | - |
| initial-load | 100000x100 | new Handsontable(...) | Grid construction only |
| source-data-validator-load | 100000x100 | new Handsontable(...) with sourceDataValidator | initial-load's fixture plus the one option |
Iterations: 3 for the scroll and cell-editing scenarios, 5 for filtering, sorting, initial-load and source-data-validator-load. Each of those four states its own reason in its scenario.config.mjs (short windows on a 300 to 350 MB heap where one GC pause moves a mean of three by 10 to 20%; for source-data-validator-load, a 20% run-to-run spread after removing the runner factor), and their iterations are cheap next to the fixture load. lib/__tests__/scenario-configs.test.mjs pins the counts, so change the config and the test together. Do not raise the scroll scenarios: each iteration is 500 wheel round trips.
Run Commands
# Full pipeline (build HOT + copy fixtures + run all scenarios)
cd performance-tests && node scripts/run.mjs
# Run specific scenario only (fixtures must exist)
npx playwright test --grep "scroll-down"
# Golden mode (saves baseline snapshot)
PERF_MODE=golden node scripts/run.mjs
# Compare mode (loads golden, generates delta report)
PERF_MODE=compare node scripts/run.mjs
# Lint
npm run lint
# Type-check spec files
npm run typecheck
Build artifacts (handsontable/dist/ and handsontable/styles/) must exist before running tests directly with npx playwright test. The scripts/run.mjs orchestrator handles building automatically.
CI Workflow
The GitHub Actions workflow (.github/workflows/performance-tests.yml) operates in two modes:
pushtodevelop: Runs all scenarios ingoldenmode, deployssnapshots.json+report.htmlto thegh-pagesbranch underperformance-reports/develop/<timestamp>/. Updateslatest.jsonas a pointer for PR comparisons. Builds a history index page listing all past runs with their commit, Chromium build and CPU. The run is also compared against the trailing median of compatible develop goldens, for its report, the job summary and one::warningannotation per regressed scenario -- so a shift on develop is seen on the develop run that introduced it. The snapshot it saves is never derived from history (teardown saves before it loads).pull_request: Fetches the last 20 develop goldens fromgh-pagesintogolden/history/(pluslatest.jsonas a single-file fallback), runs all scenarios incomparemode, posts a compact sticky PR comment with a summary table and regression callouts, and deploys the full HTML report toperformance-reports/<branch-slug>/on GitHub Pages.
Push retries with rebase (up to 3 attempts) protect against concurrent gh-pages writes.
Baseline compatibility and provenance
Replaying the develop goldens showed that the numbers move for reasons unrelated to the grid: a Playwright bump (new Chromium) shifted initial-load by -18% and sorting by -20% in one develop push, and the median-of-5 baseline carried the old browser's numbers for five more pushes, so unrelated PRs were told they had regressed. Three mechanisms now address that, all in lib/environment.mjs:
- Provenance.
lib/setup.mjs(PlaywrightglobalSetup) records the Chromium build (browser.version()), CPU model and count, memory, platform and the GitHub runner image intooutput/environment.json. The teardown stamps it on the snapshot (environment,harnessVersion) and both reports print it in their footer. - Compatibility key.
{ chromium, harnessVersion }at the snapshot level,measurementVersionper scenario.computeMedianSnapshot(..., { compatibleWith })draws only on goldens with the same key, and within a scenario only on entries at the samemeasurementVersion. A golden recorded before provenance existed has no key and is excluded by the absence of the fields, like a pre-marks golden is bywindowSource. With no compatible golden at all the comment says "no comparable baseline" and why (describeKeyMismatch, or one ofBASELINE_REFUSALSinsnapshot-store.mjs: empty history, no marks-valid golden, every scenario redefined, disjoint scenarios, an emptylatest.json). With exactly one, the single-file fallback serves and the footer says "single develop run"; the median needs two. So deltas resume with the next develop push, and as a median once two have run. BumpHARNESS_VERSIONintrace-runner.mjswhen the runner changes what a window contains (settle, GC between iterations, work moved in or out); bump a scenario'smeasurementVersionwhen only that spec's definition changes. - Run shift. The comment's
Δ vs shiftcolumn and the footer'sRun shiftline name the median delta across scenarios: how much faster or slower this runner ran than the baseline's, which every scenario shares (the per-run factor spans 0.63x-1.12x across develop goldens and removing it takes the scroll scenarios' run-to-run CV from ~13% to ~3%). It is reported, never gated on: the callouts fire on the rawΔ Total, because a change that slows every scenario alike is exactly what a median cannot see.
scripts/replay-goldens.mjs groups goldens by key before replaying, so a replay across a Chromium change never medians two environments together; its Compatibility groups block shows how many goldens sit in each.
The Trace Pipeline
Understanding the data flow helps when debugging or extending:
The measured window (read before adding a scenario)
Everything published describes the slice between the two performance.marks that
runTracedScenario() writes around the action. Two rules follow:
- No harness round trips inside
actionFn. Apage.evaluatethat reads a value back is measured as part of the operation. Put readbacks inafterActionFn, which runs after the end mark. - A
resetFnmust leave no frame behind. The runner settles aftersetupFnandresetFnfor this reason, andskipSettledoes not turn those off. Note thatscrollToRow/scrollToColumnreport trimming, not scroll position, so theirwaitForFunctionreturns before the scroll has rendered.
A category measured as exactly 0, or a CV of sqrt(n) × 100% (one nonzero iteration among
zeros, with the sample standard deviation calcCv uses: 173.21% at three iterations, 223.61%
at five), means the window is wrong -- not that the operation was cheap.
- Spec calls
runTracedScenario()-> forced GC (over a control CDP session), CDPTracing.start, start mark, action, settle, end mark,afterActionFn, forced GC +Runtime.getHeapUsagereadback,Tracing.end-> raw JSON per iteration, plusheap-after-gc.jsonfor the scenario. The GC before tracing keeps the previous reset's garbage out of the window (initial-load's third iteration read ~50% slower than its first two before it); the readback after the end mark is the live set, recorded asupdateCounters.jsHeapAfterGcBytes. Both are part ofHARNESS_VERSION2. - Teardown (
lib/teardown.mjs) discoversoutput/*/iteration-*.json, callsparseTrace()fromtrace-parser.mjs - trace-parser.mjs categorizes events into DevTools categories (scripting, rendering, painting, loading, system, idle), measures the window
runTracedScenario()marked around the action (falling back to the auto-zoomed window only for traces recorded without marks), synthesizes ProfileCall scripting from CPU profile data - Teardown averages across iterations via
averageParsedTraces(), collects per-iteration values for CV% calculation, strips internal fields (_iterationValues,_debug) from saved snapshots - report-builder.mjs assembles a compact markdown PR comment; html-report-builder.mjs generates a full interactive HTML report with inline SVG charts from chart-generator.mjs
- If
PERF_MODE=golden:snapshot-store.mjssaves averaged results, deployed to gh-pages - If
PERF_MODE=compare: teardown loads golden from gh-pages, report shows deltas
Shared Utilities
The lib/ directory provides reusable helpers to avoid duplication across scenarios:
| Module | Exports | Purpose |
|---|---|---|
fs-utils.mjs | exists(path) | Async file existence check (used by teardown, snapshot-store, run.mjs) |
scroll-utils.mjs | scrollToRow(page, row), scrollToColumn(page, col) | Scroll + deterministic wait for renderable index |
thresholds.mjs | pctChange(), classifyChange(), sumActiveComparable(), calcCv(), heapThresholdFor(), runShift(), relativeToShift(), fmtMs(), fmtPct(), fmtCv(), etc. | Shared classification and formatting for both report builders. The single source of the callout thresholds and colour bands: never restate either number elsewhere, and never retune one by eye (see scripts/replay-goldens.mjs). Heap has per-scenario overrides in HEAP_THRESHOLDS_BY_SCENARIO (the horizontal-scroll scenarios' peak heap is GC timing, CV 4.4-6.3%); read them through heapThresholdFor(name) |
environment.mjs | collectEnvironment(), baselineKey(), isCompatibleBaseline(), describeKeyMismatch(), formatEnvironment(), currentKey() | Run provenance and the baseline compatibility key; see Baseline compatibility and provenance |
hook-timing.mjs | injectHookTimer(), getHookTiming(), saveHookTimings() | Hook pair timing injection, retrieval, and persistence |
Always import from these shared modules rather than duplicating logic in scenario specs.
.mjs Convention
All .mjs files in this package follow the node-scripts-dev skill conventions:
node:prefix for builtinsnode:fs/promisesasync APIs (never sync)import.meta.dirnamefor pathsjoin()fromnode:pathfor cross-platform paths- No TypeScript syntax --
.mjsfiles are plain JavaScript. Use/** @type {any} */JSDoc casts, notas any.
Common Mistakes
| Mistake | Fix |
|---|---|
Using handsontable.full.css in fixture | Use handsontable.css -- there is no full CSS variant |
Forgetting autoRowSize: false | Always disable -- async sizing interferes with measurements |
| Scenario name doesn't match directory name | The name in config must match the directory name exactly |
Running npx playwright test without built fixtures | Run node scripts/run.mjs or build HOT first and copy dist files |
Using sync fs APIs in .mjs files | Use node:fs/promises async APIs |
Missing window.__hot in fixture | The spec's waitForFunction and page.evaluate depend on it |
Forgetting resetFn when measuring repeatable actions | Without reset, iterations 2+ start from the end state of iteration 1 |
waitForFunction() without { polling } | The rAF default is starved on a loaded machine and times out on a healthy page. Pass undefined, { polling: 100 }; the tier's eslint config bans the default |
Using waitForTimeout() for scroll/render waits | Use scrollToRow() / scrollToColumn() from lib/scroll-utils.mjs, or waitForFunction with a renderable index check |
Manually writing hook-timing.json with writeFile | Use saveHookTimings(outputDir, deltas) from lib/hook-timing.mjs |
Using TypeScript syntax (as any) in .mjs files | Use JSDoc casts: /** @type {any} */ (window) -- .mjs is not transpiled |
Defining exists() locally in a new .mjs file | Import from lib/fs-utils.mjs -- it is the single source |
| Changing what a window contains without bumping a version | Bump HARNESS_VERSION (lib/trace-runner.mjs) for a runner change, or the scenario's measurementVersion for a spec change; otherwise the median window averages two definitions of the scenario for five develop pushes |
Reading REGRESSION_CALLOUT_THRESHOLD_HEAP directly at a render site | Use heapThresholdFor(name) -- two scenarios carry a wider band, and a site that bypasses it disagrees with the callouts |
Gating a callout on Δ vs shift | Never. The shift is a median across scenarios, blind to a uniform regression; it is shown beside the raw delta so a reader can weigh it, not used to suppress anything |
Signals
- GitHub stars
- 22k
- Forks
- 3k
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
performance-testing-handsontable- Source
- github.com/handsontable/handsontable