Handsontable Playwright E2E authoring
New E2E is Playwright in tests/e2e/ (*.spec.ts). The legacy Jasmine *.spec.js suite is frozen — never add a new one. Reference: tests/e2e/grid.spec.ts + tests/fixtures/pages/GridPage.ts.
Where tests RUN (scope discipline)
Locally, run only the specs you created or changed — never the full suite,
and under the default theme only (fast smoke):
cd tests && npx playwright test --project=e2e-main e2e/<your-spec>.spec.ts.
The Stop hook and pre-push do exactly this automatically (session-touched /
branch-touched specs). Every spec is parametrized across a theme
(main/horizon/classic) × bundle (umd = handsontable.js, full-min =
handsontable.full.min.js) matrix — six projects, 1:1 with the Puppeteer
legs; the full matrix belongs to CI (one E2E / Playwright <bundle> (theme: …) job per leg). The local gates run e2e-main (plain UMD) only —
the -min legs are CI-only. To run all legs locally for one spec, drop the
--project filter: npx playwright test e2e/<your-spec>.spec.ts. npm test
in tests/ (the whole suite × all themes) locally = wasted minutes, never
required evidence.
Check port 8123 before you believe a local result
The webServer config uses reuseExistingServer, so Playwright attaches to
whatever already listens on port 8123 instead of starting its own. A second
checkout — a worktree beside the main clone, or another session's leftover
support/static-server.mjs — therefore serves its handsontable/dist/, and
your specs silently exercise a build you did not make. Nothing in the output says
so: the run just passes, or fails for reasons your diff cannot explain.
Check first, every time you run locally:
lsof -nP -i :8123 | grep LISTEN # empty = free, safe to run
If something is listening and it is not yours, do not kill it — another session may be mid-run. Give your run its own port instead:
cd tests && HOT_TEST_PORT=8131 npx playwright test --project=e2e-main e2e/<your-spec>.spec.ts
HOT_TEST_PORT is read by tests/playwright.config.ts and passed explicitly to
both the server and the base URL. It throws on a malformed or empty value rather
than falling back to 8123 — the collision it exists to escape. This knob is the
functional suite only; the visual suite's port is owned by
visual-tests/src/config.mjs plus two hardcoded app.listen(8082) demo servers,
so it cannot be moved this way.
Rebuild BOTH bundles before an all-legs run
npm run build:umd writes dist/handsontable.js only — the minified bundle the
three -min legs load comes from the separate build:umd.min, so a partial
build leaves the -min legs on your previous code. Use the full task, then
check that both files actually moved:
npm --prefix handsontable run build
ls -l handsontable/dist/handsontable.js handsontable/dist/handsontable.full.min.js
And read a mixed result PER LEG before calling it a race — a split that falls
exactly along the bundle axis is about the bundles, not about timing. Why that
happens, how to tell a stale bundle from a genuine full.min difference, and
what it cost: tests/AGENTS.md.
Four rules (non-negotiable)
- Page Object Model. A spec expresses intent; selectors and interactions live in a page object under
tests/fixtures/pages/. Never put raw selectors or multi-step flows in a spec — when the DOM shifts, one file changes. - Hook by
data-testid, not structural CSS. Stamp ids in the fixture (or add them to the component when it removes ambiguity). Fall back to role/text locators before ever reaching into grid internals. - Web-first waits only.
await expect(locator).toBeVisible()— neversleep/waitForTimeout/networkidleor a custom ready flag. Await every assertion (a missing await is the sneakiest flake). Inside a page object the same rule has six shapes lint cannot see —setTimeoutinpage.evaluate(), awaitForFunctionwithout{ polling }, a scroll method that ends onscrollTop,.at(-1)on a separately read log, a fixture build that fails silently, a negative settle with no positive control — each measured on a migration and spelled out inreferences/determinism.md. - Isolation, no flake. One instance per test;
page.route()/page.clock()for network/time.failOnFlakyTestsis on in CI — pass-on-retry is a hard failure. - Thread the bundle axis. Import
testfromtests/fixtures/test.ts, destructure{ page, theme, bundle }, and pass both to the page object. A new fixture copies the fail-loud?theme=/?bundle=allowlist block fromdemo/grid.html— never a hardcoded bundle<script src=…>. Formulas specs load HyperFormula as an external script in the fixture (theumdlegs' base bundle ships none). The never-get-wrong list:tests/AGENTS.md.
Which test — decide, then route
- User-visible (rendering, interaction, keyboard, menus, overlays) → E2E here.
- Invisible to users (data, indexing, algorithms) → Jest
*.unit.js— still mandatory. - Pure refactor / non-runtime → no new test; declare
Refactor-only: <reason>in the commit. - New API / plugin / editor → new spec. Bug fix → a failing case in the closest existing spec.
- Broken or flaky legacy Jasmine → migrate to Playwright, don't patch it.
Full decision rules: handsontable/.ai/TESTING.md.
Test granular user actions — HOT is a library, not an app
Handsontable implements the low-level interactions; you cannot assume the layer beneath "just works" the way you would when testing an app built on a framework. Scenario generation must target fine-grained user actions and their correctness, not app-level happy paths:
- Scrolling — wheel, drag-the-scrollbar, keyboard, and momentum / inertial scroll; frozen rows/cols and overlays staying aligned during and after the scroll.
- Pointer — hover (highlights, handles, tooltips), click / dblclick, context menu, drag-to-select, the fill-handle drag, column/row resize and move drags.
- Keyboard — navigation, range selection, editing, shortcuts, enter/escape commit semantics; IME composition for CJK input.
- Touch — tap, long-press, touch scroll/drag on mobile viewports.
- Layout — RTL, container resize / ResizeObserver, viewport edges, large-dataset virtualization boundaries.
Overlay-clone gotcha (hooking): column/row headers and frozen rows/cols are
rendered in multiple overlay layers (.ht_clone_top, .ht_clone_inline_start, the
corner, plus the master), so a data-testid stamped in a renderer or a header hook
appears more than once in the DOM. Scope the locator to the overlay you mean
(e.g. page.locator('.ht_clone_top').getByTestId(...)) or the strict-mode match
fails. Plain (unfrozen) cells live only in .ht_master, so they hook cleanly.
Prefer a driven, observable check (perform the action, then assert the exact cell /
overlay / selection state) over "it rendered". A scenario like momentum scroll keeps
the frozen top overlay aligned is the right altitude — the class of bug only a
real-browser, low-level test catches. Fewer such tests, each extremely meaningful,
beats many shallow ones — judge a test by whether it would catch a real bug, not by
what it executes. Full discipline: the test-writing-discipline skill.
Recording
Record with the Playwright CLI (npx playwright codegen), not a Playwright MCP — the CLI is the version-pinned tool installed here (CI parity) and produces code you keep. Refactor the output into a page object with data-testid selectors before committing.
References (load as needed)
references/page-objects.md— POM, test ids, grid locators, editing, fixtures & the demo server.references/wrappers.md— React / Angular / Vue wrapper E2E: StrictMode, NgZone, reactivity, lifecycle, driving the example apps.references/codegen.md— recording via the CLI,--ui, trace viewer, refactoring the output.references/determinism.md— the flake-free checklist.
TypeScript style is not repeated here — Playwright specs follow the repo's TS conventions (handsontable/.ai/CONVENTIONS.md).