To avoid unintended changes to Handsontable's UI, we use visual regression testing.
We run visual tests automatically by using the following tools:
| Tool | Description |
|---|---|
| Playwright | An open-source testing framework backed by Microsoft. We use it to write and run visual tests. |
| reg-suit | An open-source visual regression suite. We use it to compare screenshots and to publish an HTML report. |
| Cloudflare R2 | Object storage. We use it to hold the golden records and to serve the diff reports. |
| GitHub Actions | GitHub's CI platform. We use it to automate our test workflows. |
When you push changes to a GitHub pull request:
-
The Lint / visual tests check (
lint.yml) checks the code of each visual test: no fixed delays, an asserted state before every capture that follows a click or a key press, and a docblock on every test that names the ticket owning it. -
The Tests workflow runs all of Handsontable's tests.
-
After all tests pass successfully, the Visual workflow renders the
prtier — the vanilla JS tests on Chromium with themainandmain-darkthemes, plus a wrapper when your change is in its scope — and compares the screenshots against the matching golden records. A pull request that touches the visual tests themselves (visual-tests/orexamples/next/visual-tests/) renders everything instead. The other themes, browsers, and wrappers are rendered by the base branch's seed and by the weekday nightly; see Tiers. -
The golden records come from the branch your pull request targets — usually
develop. Every seed-tier build of a base branch rewrites that branch's golden records, so a pull request intodevelop,master, or a release branch is compared against the right baseline with no extra configuration. Ondevelopthe seed is its own workflow,Visual seed(.github/workflows/visual-seed.yml), which runs on every push under a concurrency group that never cancels, so the last push of any burst is always seeded, usually within about 15 minutes;masterand the release branches seed through their own pipelines as before.That window is the one thing to know before you read a diff: a pull request opened in the middle of a burst, or in the quarter of an hour after the last push, is compared against a baseline that does not yet contain every merged commit. If a difference looks like someone else's change, check whether the latest
Visual seedrun has finished before assuming it is yours.Exception — LTS branches. No workflow currently runs the visual tests on a push to
lts/*, so an LTS baseline is created by the first pull request into that branch and is never replaced. Later LTS pull requests are therefore compared against one contributor's unreviewed screenshots. Treat an LTS result as advisory until an LTS push trigger exists.
If reg-suit spots differences, the Visual / approve job of your pull request's Tests run waits for a
reviewer, and CI Gate waits with it, so you can't merge yet. In that case:
- Open the report. The Visual workflow comments the report URL on your pull request. If that URL is
unreachable, download the
visual-diff-reportartifact from the workflow run instead and open itsindex.html. The artifact holds the images of every changed, new, and deleted screenshot; the passing ones are listed without images. - Decide what the differences mean:
- They are a regression. Push a commit that removes them; the next run compares again and the approval request goes away on its own.
- They are intentional. A reviewer opens the workflow run and selects Review pending deployments → visual-approval → Approve (the deployment's "View deployment" button opens the report). One click: no new commit, no re-run. Reject turns the run red. Approval covers the whole build — there is no per-screenshot review — so read the report first.
Approval binds to one run. A new push starts a new run and asks again, so screenshots nobody has looked at never inherit an earlier approval. GitHub records who approved and any comment they left, and the comment on your pull request is rewritten to name them, so it stops asking for a review that has already happened.
Two cases worth knowing:
- Your pull request comes from a fork, or from Dependabot. Those runs get no secrets and publish no
report, so the
visual-diff-reportartifact on the run holds the report and the images of every changed, new, and deleted screenshot (the passing ones are listed without images), and the job summary carries the verdict. The approval works exactly the same: a maintainer approves the pending deployment. - A visual change merged into the branch you target. The golden records always come from that branch's
latest build, so once someone else's intentional change lands, your next run inherits their differences
as well as yours. Merge the target branch into yours and push. Approving would also approve any real
regression of your own that the same build contains. Do not rebase and force-push a branch bound to a
pull request. Two timings make it worse, and when the change moved the golden set's counts the
Visual budgetsection tells them apart and fails the run. Do not add a[visual budget: …]marker for either. If the change landed while your run was rendering, your render met goldens newer than the code it built: merge and push, because "Re-run jobs" replays the same merge commit. If it landed just before your run but its seed had not finished, your render met goldens older than the code it built: re-run onceVisual seedhas finished for the target branch. A change that restyles without moving the counts is caught by neither, and only merging the target branch keeps you clear of it.
If the branch you target has no golden records yet, the check does not fail. The build promotes its own
screenshots to that branch's golden records and passes, so a fresh branch cannot wedge every pull request
opened against it. The next build of the base branch overwrites them with the authoritative render — on
every base branch except lts/*, which has no push trigger (see the exception above).
A full render is 1676 screenshots (the develop golden set on 2026-09-18) and adds about 15 minutes to a pull request run, and in 82 measured pull
requests no real regression was confined to one theme, one browser, or one wrapper. So a pull request
renders the variants that catch regressions, and the rest are rendered by the seed right after the merge
and reported back to the merged pull request. The tiers are VISUAL_TIERS in visual-tests/src/config.mjs;
visual.yml receives the one to render as its tier input.
| Tier | When | Renders | Compared against | Writes the golden records? |
|---|---|---|---|---|
pr |
every pull request | vanilla JS on Chromium, main + main-dark; a wrapper only when the change touches that wrapper's own wrappers/<pkg>/ tree |
the matching subset of the target branch's golden records | no |
seed |
a push to develop (Visual seed), master, or a release branch |
vanilla JS in the classic delivery path and all four themes, the cross-browser tests, and the wrapper records: each spec's classic JS render copied into the wrappers that spec declares | the branch's previous golden records | yes — this is what a pull request compares against |
full |
the weekday nightly on develop (Visual nightly); a pull request that touches visual-tests/ or examples/next/visual-tests/ |
everything, with the wrappers rendered for real | the golden records of develop (nightly) or of the target branch (pull request) |
never — the nightly publishes its report to nightly/develop/ |
Two things cover what a pull request does not render. The develop seed renders the classic path, all four
themes, and the cross-browser tests minutes after each merge, and when the merge changed any of those it
comments the list on the merged pull request (the Visual seed run summary carries the same list),
so a horizon-only, Firefox-only, or WebKit-only change is attributed to its author the same afternoon,
and those renders are now the golden records. The nightly is the only build that renders the wrappers for
real (the seed copies the vanilla JS render into their golden records), and it turns red on any
difference from the seed outside the visual quarantine (visual-quarantine.json, which lists a known-flaky
capture instead of failing on it): a wrapper that no longer renders like vanilla JS, a flaky or poisoned golden
record, or a commit whose seed never landed. A theme-only regression cannot red the nightly, because the
seed has already made it the baseline; the seed's comment is where it shows. visual-tests/AGENTS.md
has the diagnostic for a red nightly.
flowchart TD
PR["Push to a pull request"] -->|"pr tier<br/>(full when it touches visual-tests/)"| RENDER
DEV["Push to develop or a release branch"] -->|"seed tier"| RENDER
NIGHT["Weekday nightly on develop"] -->|"full tier"| RENDER
subgraph RENDER["Render (matrix, sized by the tier)"]
RPR["pr: js on chromium,<br/>main + main-dark<br/>(+ a wrapper whose own tree changed)"]
RSEED["seed: js classic + 4 themes,<br/>wrappers copied from js,<br/>cross-browser chromium, firefox, webkit"]
RFULL["full: everything,<br/>wrappers rendered for real"]
end
RENDER --> KEYS{"Which ref?"}
KEYS -->|"pull request"| KPR["expected = base/TARGET<br/>actual = pr-NUMBER/SHA"]
KEYS -->|"base branch"| KBR["expected = actual = base/BRANCH"]
KEYS -->|"nightly"| KNI["expected = base/develop<br/>actual = nightly/develop<br/>(never written to base/)"]
KPR --> PROBE
KBR --> PROBE
KNI -->|"credentialed, never promotes"| SUIT
PROBE{"Do golden records exist?<br/>GET /base/BRANCH/out.json"}
PROBE -->|"404, none yet"| SEED["Promote this build to<br/>the golden records"]
SEED --> PASS
PROBE -->|"200"| WHO{"Fork or Dependabot?"}
WHO -->|"no, has secrets"| SUIT["compare.mjs<br/>fetch, prune to the tier, diff, publish"]
WHO -->|"yes, no secrets"| FORK["compare-fork.mjs<br/>anonymous HTTPS, the tier's subset,<br/>publishes nothing"]
SUIT --> OUT["screenshots compared<br/>.reg/out.json"]
FORK --> OUT
OUT -->|"pull request"| GATE{"visual-gate.mjs<br/>any differences?"}
OUT -->|"base branch or nightly"| REPORT["seed-report.mjs writes the run summary;<br/>the nightly goes red on any difference<br/>outside the quarantine"]
GATE --> COMMENT["visual-gate.mjs writes the comment,<br/>sticky action posts it"]
GATE -->|"none"| PASS["Check passes, PR mergeable"]
GATE -->|"differences found"| WAIT["approve job waits on the<br/>visual-approval environment"]
WAIT --> REVIEW["Reviewer opens the report URL<br/>or the visual-diff-report artifact"]
REVIEW -->|"a regression: fix it"| PR
REVIEW -->|"intentional: Approve"| PASS
REVIEW -->|"Reject"| FAIL["Job fails, PR blocked"]
KBR -.->|"rewrites the baseline"| BUCKET[("Cloudflare R2<br/>base/BRANCH/actual/")]
SEED -.-> BUCKET
BUCKET -.->|"read as expected"| PROBE
CLOSED["Pull request closed"] --> PURGE["Delete pr-NUMBER/ from R2"]
Two behaviors are worth reading off the diagram:
- Approval is all or nothing, and one click. A
changedverdict holds the run on thevisual-approvalenvironment; the reviewer approves or rejects the pending deployment on the run page. An approval covers exactly the screenshots of that run: a new push is a new run and asks again. - A missing baseline never blocks. The first build for a branch promotes its own screenshots to the golden records and passes. The next build of that branch replaces them, so an unreviewed baseline survives at most one merge.
- The nightly reads the golden records and never writes them. It renders the wrappers for real, while the golden records hold the JS render copied into them, so its shape is not the baseline's. Its report lives at
nightly/develop/and is rewritten each night; a red nightly means develop changed since the seed, or a golden record went bad.
Visual tests are divided into:
- multi-frameworks: tests of the shared
/grid, run on Chromium against a Handsontable instance created in each framework the tier renders and the spec declares (see Tiers — a pull request renders vanilla JS with themainandmain-darkthemes; the seed and the nightly render the classic delivery path and all four themes too; one spec, the parity canary, declares the three wrappers):- Vanilla JS
- Angular
- React
- Vue 3
- cross-browser: tests run against vanilla JS Handsontable instance using:
- Chromium
- Firefox
- Webkit
There is a separate Playwright config for cross-browser tests: playwright-cross-browser.config.ts
The directory decides which Playwright config runs a spec and therefore which leg it belongs to. Inside
that leg, the variants a spec renders on are its own declaration: every spec registers through
visualTest(title, { themes, browsers, wrappers, wrappersReason }, fn), and a variant it does not declare
skips at file scope for nothing. A new spec renders two themes on Chromium with no wrapper unless it says
otherwise. See AGENTS.md, Variant declaration, for the axes and the invariants.
All the test examples are available at examples/next/visual-tests and configured to be served from localhost:8082
The main demo, served for every framework on /, is the shared multi-feature grid that the multi-frameworks
specs photograph; the react and angular demos also serve /scenario-grid (the vue3 demo has no router and
serves / only). The vanilla JS demo adds one /<name>-demo
route per feature (27 on 2026-09-18 — the list is the Navigo router in
examples/next/visual-tests/js/demo/src/index.js), used by the js-only and cross-browser specs. A new
feature gets its own route there, never a change to /: the rule and its trade-off for wrapper coverage are
in AGENTS.md → Decision rule.
Our GitHub Actions configuration runs the visual tests automatically; approving intentional differences needs no re-run. To render again by hand (a flake you want to rule out):
- On GitHub, at the bottom of your pull request, find the Visual / Compare check. Select Details.
- On the left, next to the Compare job, select 🔄.
- Select Re-run jobs.
You can manually run visual tests on your machine and then compare the resulting screenshots against the golden records.
First, prepare your local visual testing environment:
- Make sure you're using the Node and npm versions mentioned here.
- From the
./visual-tests/directory, runnpm install. - From the repository root, build Handsontable:
npm --prefix handsontable run build. The demos render this local build, so build it again after you change the core, or after a checkout, pull, or rebase that changes its sources. To render a wrapper demo, build that wrapper too, for examplenpm --prefix wrappers/react-wrapper run build; a barenpm run buildon a feature branch renders all three.npm run buildchecks for these builds before it installs anything. Each demo's own build refuses to run against a copy of Handsontable from the npm registry, or against a core build older than its sources. Each problem they report ends with the command that fixes it. - In the
./visual-tests/directory, create a file called.env. In the file, add the R2 credentials:Ask your supervisor about the values.AWS_ACCESS_KEY_ID=xxx AWS_SECRET_ACCESS_KEY=xxx R2_BUCKET_NAME=xxx R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com VISUAL_REPORT_DOMAIN=xxx
To run the visual tests locally:
-
From the
./visual-tests/directory, run one of the following commands:Command Action VISUAL_TIER=pr npm run build && VISUAL_TIER=pr npm run testRender what a pull request renders (the prtier):
the vanilla JS tests on Chromium with themainandmain-darkthemes.
Skips the wrapper installs (Angular alone is about two minutes) and the other themes.npm run testRun multi-framework visual tests on Chromium
for the tier the branch resolves to: everything on a feature branch (full),
vanilla JS copied into the wrappers ondevelop(seed).npm run test:cross-browserRun cross-browser visual tests,
using vanilla JS framework,
for all the supported browsers.
You can pass the test name to run a single cross-browser test:npm run test:cross-browser bordersnpx playwright test {{ file name }}Run a specific test.
For example:npx playwright test column-selectionThe resulting screenshots are saved in
./visual-tests/screenshots/. -
From the
./visual-tests/directory, set the snapshot keys and run the comparison:REG_EXPECTED_KEY=base/develop REG_ACTUAL_KEY=local/$(git rev-parse --short HEAD) npm run comparecompareloads./.envitself if the file exists, so the credentials from step 4 are picked up without exporting them by hand. If you rendered theprtier, prefix this command withVISUAL_TIER=pras well, so the golden records are trimmed to the same subset; otherwise the report lists every record you did not render as deleted. A local run never writes tobase/, so it cannot overwrite a golden record. -
Open the report URL printed in the terminal, or open
./visual-tests/.reg/index.htmldirectly.
To add a new visual test:
- On your machine, in the
./visual-tests/tests/directory, create a new.spec.tsfile.
Give your file a descriptive name. This name is later used in test logs and screenshot names.- ✅ Good:
open-dropdown-menu.spec.ts. - ❌ Bad:
my-test-1.spec.ts.
- ✅ Good:
- Copy the template code from
./visual-tests/tests/multi-frameworks/.empty-test-template.tsinto your file. - Write your test. For more information, see:
- Push your changes to a pull request.
The Lint / visual tests check (lint.yml) checks the code of your test, including the assertion before each capture and the test's docblock.
To capture a screenshot and save it to a file, add this line anywhere in your test:
await page.screenshot({ path: helpers.screenshotPath() });Capture one screenshot per distinct visual state, and assert that state first (await expect(locator).toBeFocused(), .toBeVisible(), …) — a capture straight after an action photographs whichever half of the transition the runner reached. What earns a capture at all, and why a visual spec is in addition to, never instead of a Playwright assertion, is AGENTS.md → Decision rule; the determinism rules are in AGENTS.md → Determinism. For example:
await cell.click();
await expect(cell).toHaveClass(/current/);
await page.screenshot({ path: helpers.screenshotPath() });
await anotherCell.click();
await expect(anotherCell).toHaveClass(/current/);
await page.screenshot({ path: helpers.screenshotPath() });To capture one element of Handsontable, clip the page capture to the element's box. Playwright's
locator() finds it; locator.screenshot()
is a lint error here because it bypasses the settle wait and the selection clear the fixture runs before every
capture. For example:
const dropdownMenu = page.locator(helpers.selectors.dropdownMenu);
await expect(dropdownMenu).toBeVisible();
await page.screenshot({ path: helpers.screenshotPath(), clip: await dropdownMenu.boundingBox() });For cross-browser tests we are using
await page.screenshot({ path: helpers.screenshotMultiUrlPath(testFileName, url, suffix) });for easier screenshot identification.
To write tests faster, use the custom helper functions and variables stored in the ./visual-tests/src/helpers.ts file.
Returns the current modifier key: Ctrl for Windows or Meta for Mac.
// copy the contents of the selected cell
await page.keyboard.press(`${helpers.modifier}+c`);Returns true if the test runs on Mac.
if (helpers.isMac) {
// do something
}Returns the specified cell.
Syntax: findCell({ row: number, cell: number, cellType: 'td / th' }).
const cell = helpers.tbody.locator(helpers.findCell({ row: 2, cell: 2, cellType: 'td' }));
await cell.click();Returns the button that expands the dropdown menu (also known as column menu) of the specified column.
Syntax: findDropdownMenuExpander({ col: number }).
// select the column menu button of the second column
const changeTypeButton = table.locator(helpers.findDropdownMenuExpander({ col: 2 }));
await changeTypeButton.click();