Handsontable E2E Testing Guide (legacy Jasmine/Puppeteer — frozen)
SkillWeb & browsingUse ONLY when maintaining the FROZEN legacy Jasmine/Puppeteer E2E suite (*.spec.js) — editing an existing spec, or migrating a broken one to Playwright. Do NOT use for new E2E: new E2E is Playwright (skill handsontable-playwright-e2e). Covers the legacy boilerplate, async/await rules, global helpers, event simulation, and theme-agnostic assertions.
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 Handsontable E2E Testing Guide (legacy Jasmine/Puppeteer — frozen) skill
What this skill tells your AI
The instructions your AI receives, as published by handsontable/handsontable in .claude/skills/handsontable-e2e-testing/SKILL.md and read by ahel’s review.
This suite is frozen. New E2E tests are Playwright — use the
handsontable-playwright-e2eskill and put them intests/e2e/. This guide is for maintaining existing*.spec.jsfiles. The presence gate blocks a newly added*.spec.js, and appending three or more newitblocks to a modified frozen spec draws its non-blockingfrozen-suite-growthadvisory — state the justification in the PR if the frozen tier is right. If a legacy spec is broken or flaky, migrate it to Playwright rather than patching it here.
Standard boilerplate (MUST follow)
Every E2E test file must use this structure exactly:
describe('MyFeature', () => {
const id = 'testContainer';
beforeEach(function() {
this.$container = $(`<div id="${id}"></div>`).appendTo('body');
});
afterEach(function() {
if (this.$container) {
destroy();
this.$container.remove();
}
});
it('should do something', async() => {
handsontable({ data: createSpreadsheetData(5, 5) });
await selectCell(0, 0);
expect(getDataAtCell(0, 0)).toBe('A1');
});
});
Critical rules (ESLint enforced)
- ALL
it()callbacks MUST beasync. - HOT API calls MUST be
await-ed (~50+ methods). - Forgetting either causes flaky tests.
Global helpers (NO imports needed)
These are injected automatically. Do not import them manually.
- Instance:
handsontable(),destroy(),updateSettings(),render() - Data:
createSpreadsheetData(),getDataAtCell(),getData(),setDataAtCell() - Structure:
countCols(),countRows(),alter() - Selection:
selectCell(),selectCells(),getSelected(),getSelectedRange() - DOM:
getCell(),spec(),hot() - Plugins:
getPlugin()
Prefer the bare global over the hot(). form. Most instance methods are exposed as bare globals that proxy the active instance, so write countCols() not hot().countCols(), and await alter('remove_col', 2, 1) not hot().alter('remove_col', 2, 1). The mutating globals (alter(), setDataAtCell(), selectCell(), …) auto-render, so they MUST be await-ed. Only reach for hot() when you need a method that has no bare-global wrapper.
- Theme layout:
getLoadedTheme(),getThemeLayout()(seehandsontable/.ai/TESTING.md) - Iframe
doc.writetheme CSS:getE2eThemeStylesheetLinkTagsHtml()(all themes),getE2eThemeStylesheetLinkTagHtml(key),getE2eNormalizeStylesheetLinkTagHtml()- fromcommon.js; theme list isE2E_REGISTERED_THEME_KEYSinthemeLayoutFromTokens.js, auto-discovered fromsrc/themes/theme/index.ts(add a theme there and the list updates automatically). - Full list in
test/helpers/common.js.
Theme-agnostic assertions
Every test must pass under every theme. Never branch on getLoadedTheme() or hardcode per-theme pixel values in specs - use getThemeLayout() token helpers or live DOM measurements instead.
Use const layout = getThemeLayout() (token-backed; merged API from test/helpers/themeLayoutFromTokens.js, which exposes token primitives, overlayHeight / verticalScrollForRow helpers, and scenario-specific e2e* regression helpers with descriptive names like e2eGcrEditedCellOuterHeight, e2eManualRowResizerPositionFixedTopMasterFourthRow, etc.).
Entry point: themeLayoutFromTokens(themeName) reads density and tokens from handsontable/src/themes/theme/<name>.ts. Changing a theme's density in that module propagates to all tests automatically.
Fundamental rule: All expectations must be pure expressions over tokens + density tokens + sizing tokens, or derived from live DOM measurements. Numeric density triplets ({ compact: N, default: N, comfortable: N }) are not used anywhere.
When a value is not token-derivable (text shaping, autosize widths, pixel rounding), compute it from the live DOM or assert a relational property instead of branching on the theme:
- Plugin API reads:
hot().getColWidth(col),hot().getRowHeight(row),hot().getPlugin('autoColumnSize').getColumnWidth(col) - DOM measurements:
getCell(r, c).offsetWidth/offsetHeight,$el.getBoundingClientRect(),window.getComputedStyle(el).padding* - Relational assertions:
toBeGreaterThan(previousValue),toBeLessThanOrEqual(containerWidth) - Tolerance-based comparisons:
toBeAroundValue(expected, 2)orexpect(Math.abs(actual - expected)).toBeLessThanOrEqual(1)
Viewport helpers (all globals from common.js):
expectedVisibleRows(containerHeight, colHeaderRows = 1)- number of fully visible data rowsexpectedLastFullyVisibleRow(containerHeight, colHeaderRows = 1)- 0-based index of the last fully visible rowcontainerHeightForRows(rowCount, colHeaderRows = 1)- height that guarantees exactlyrowCountfully visible rows (prefer this over hardcodedheight: 200)scaleHeight(mainThemeHeight)/scaleHeightWithScrollbar(mainThemeHeight)- scale a main-theme pixel height proportionally to the current theme's row height (useful when porting tests that used a fixed height)getPaginationContainerHeight()- measures the live pagination bar height; theme/density/token independent
Prefer, in order: (1) named layout.e2e*() helpers when a shared formula exists (e.g. layout.e2eGcrEditedCellOuterHeight()), (2) a direct formula in primitives (layout.defaultDataRowHeight + layout.cellBorderWidth), (3) a DOM/plugin-API read, (4) a relational assertion. Do not branch on layout.densityLevel or theme name in specs - the primitives already vary per theme.
Adding a new theme: See the handsontable-css-dev skill for the full four-layer token process. E2E-specific steps: (1) tokens at src/themes/static/variables/tokens/<name>.ts, (2) colors at src/themes/static/variables/colors/<name>.ts, (3) icons at src/themes/static/variables/icons/<name>.ts (or reuse an existing one), (4) CSS source src/themes/static/css/theme/ht-theme-<name>.css + -no-icons.css variant, (5) theme module src/themes/theme/<name>.ts exporting { name, density, icons, colors, tokens }, (6) re-export from src/themes/theme/index.ts, (7) add any new token keys to the VALID_TOKEN_KEYS allow-list in src/themes/engine/utils/validation.ts, (8) add any new token keys to the TokenKey union in src/themes/types.ts, (9) add E2E matrix jobs in .github/workflows/test.yml. No edits needed to themeLayoutFromTokens.js, common.js, or any spec file - auto-discovery handles the rest.
Do not branch on getLoadedTheme() in spec files for pixel expectations. Every test should run under every theme.
See handsontable/.ai/TESTING.md ("Data-Driven Theme Assertions") for full details and all available metrics.
Event simulation
- Mouse:
mouseDown(),mouseUp(),mouseOver(),mouseClick(),mouseDoubleClick()fromtest/helpers/mouseEvents.js - Keyboard:
keyDown(),keyUp(),keyDownUp()fromtest/helpers/keyboardEvents.js - Touch:
triggerTouchEvent(type, target),simulateTouch(target)fromtest/helpers/common.jstriggerTouchEvent('touchstart', element)/triggerTouchEvent('touchend', element)— dispatches a single touch eventsimulateTouch(element)— full Android sequence: touchstart → touchend → mousedown → mouseup → click (withpreventDefaulthandling)- Both must be
await-ed in spec files
Testing touch / mobile behavior
When testing touch interactions (editors opening on double-tap, outside-click after touch, etc.):
it('should open editor on double-tap', async() => {
handsontable({ data: createSpreadsheetData(5, 5) });
const cell = getCell(0, 0);
// First tap — select
await triggerTouchEvent('touchstart', cell);
await triggerTouchEvent('touchend', cell);
// Second tap — open editor
await triggerTouchEvent('touchstart', cell);
await triggerTouchEvent('touchend', cell);
// Assert editor opened
});
Use simulateTouch(target) when you need to test the full Android event sequence including synthetic mouse events.
Waiting in an edited spec (hard rules)
The frozen suite's flakiness is timing debt. An edit must not add to it, and a broken or flaky spec migrates (see the top of this file) — these rules cover the edit you MUST make in place.
- A rendered-DOM count assertion pins the viewport first.
countRenderedRows(),countRenderedCols(), and anytbody trcount depend on how many rows the container shows, which varies per theme and per machine. Size the container withcontainerHeightForRows(n)orscrollViewportTo()the target into view before counting. An unpinned count is a per-theme coin flip. waitUntil(condition, timeout)replacessleep()andwaitForNextAnimationFrames(). It is a spec global fromtest/helpers/common.js: it polls the condition every frame and rejects with a named reason when the state never arrives.waitForNextAnimationFrames()is a fixed sleep denominated in frames — it awaits at most 2 real frames (normalizeFrameCountcaps it) and pads the rest of the request with 16 ms per frame — so it issleep()in a different unit. Everysleep()call warns today (handsontable/no-fixed-sleep-in-specruns at warn level so the existing debt surfaces without red-walling CI); a diff-scoped ratchet that fails a NEWsleep()on an added line is landing in a sibling PR — treat a new one as an error now.it.flaky()is not a wait. A retry hides a race, it does not remove one; newit.flaky()sites are lint-warned (handsontable/no-new-it-flaky). A spec that needs a retry is a spec to migrate to Playwright.
What to test for plugins
- Enable via settings:
handsontable({ myPlugin: true }) - Disable via
updateSettings({ myPlugin: false }) - Programmatic:
getPlugin('myPlugin').enablePlugin()/.disablePlugin() - Non-consecutive selections and header selections.
- Coordinate system edge cases (physical vs visual vs renderable).
Run commands
- All:
npm run test:e2e --prefix handsontable - Targeted:
npm run test:e2e --prefix handsontable --testPathPattern=<regex>- the pattern is matched against test file paths during the Rspack.dumpstep (e.g.collapsibleColumns,ghostTable,textEditor,nestedHeaders/__tests__/hidingColumns) - With theme:
npm run test:e2e --prefix handsontable --testPathPattern=<regex> --theme=horizon(available themes:classic,main,horizon; default when--themeis omitted:main) - Rebuild first: The E2E runner loads
dist/handsontable.js. After changingsrc/**, runnpm run build --prefix handsontablebefore running E2E tests.
Parallel runs: Multiple npm run test:e2e --prefix handsontable --testPathPattern=<X> invocations with different patterns (or themes) can run simultaneously. The dump step hashes testPathPattern + theme into a short run ID and writes per-run artifacts (test/dist/main.entry.<runId>.js and test/E2ERunner-<runId>.html), and the Puppeteer runner picks its own free port starting at 8086 (retries up to 100 ports). Nothing special needs to be passed - just launch the commands; the practical limit is machine resources, not the tooling.
Iterating on a single area: Prefer test:e2e.watch - it leaves the dev server running and re-bundles + re-runs on every source change, so you don't have to stop and restart between edits:
npm run test:e2e.watch --prefix handsontable --testPathPattern=filters --theme=horizon
Under the hood it spawns the regular Rspack dump in --watch mode and reopens the browser page, reusing the generic test/E2ERunner.html (no run ID needed - the dump and puppeteer halves share one npm process, so the flags propagate automatically).
One-shot run: Use npm run test:e2e --prefix handsontable --testPathPattern=<regex> --theme=<theme> - the wrapper script passes the flags to both dump and puppeteer via env, so there's no risk of a mismatch.
Split dump + puppeteer (what CI does): if you invoke the two steps in separate npm run commands, pass --testPathPattern AND --theme to both. Each npm run is its own npm process with its own env, and the Puppeteer script recomputes the same hash as dump to find the runner HTML - a mismatch fails with "Runner HTML not found at ...". .github/workflows/test.yml is the canonical example; the same rule applies to test:production.dump + test:e2e.puppeteer.
A generic test/E2ERunner.html (no run ID) is always regenerated alongside the per-run variant for developer manual testing in a browser. Specs that inject iframes with relative CSS paths (e.g. afterRefreshDimensions, Selection) rely on the runner living in test/, which is why the per-run HTML stays there too.
Debugging (capturing values from the browser)
E2E specs run inside a headless browser, so a plain console.log is NOT printed to your terminal. The Puppeteer runner (test/scripts/run-puppeteer.mjs) forwards only page console messages whose text starts with DEBUG, printing them as [BROWSER] <text>:
it('should ...', async() => {
handsontable({ /* ... */ });
// Prefix with DEBUG so the runner forwards it to your terminal.
console.log(`DEBUG state ${JSON.stringify({ labels: getColHeaders(), count: countCols() })}`);
});
Then filter the run output: npm run test:e2e --prefix handsontable --testPathPattern=<regex> 2>&1 | grep DEBUG.
Notes:
JSON.stringifyomits keys whose value isundefined- a missing key in the output usually means the value wasundefined, not that the line is stale. UseString(value)when you need to distinguishundefined/false/null.- For a quick yes/no check you can also just
expect(actual).toEqual('SENTINEL')and read the "Expected ... to equal" diff - assertion failures always reach the terminal.
Test location
All E2E tests live under src/ alongside the code they test. The spec filename must match the method, hook, or setting name exactly (e.g., getSourceData.spec.js, afterChange.spec.js, height.spec.js).
| What is tested | Directory |
|---|---|
Core method (e.g., getSourceData) | src/__tests__/core/<methodName>.spec.js |
Hook (e.g., afterChange) | src/__tests__/hooks/<hookName>.spec.js |
Setting (e.g., height) | src/__tests__/settings/<settingName>.spec.js |
| Plugin | src/plugins/{name}/__tests__/*.spec.js |
| Keyboard shortcuts | src/shortcuts/__tests__/keyboardShortcuts/<name>.spec.js |
| i18n | src/i18n/__tests__/<name>.spec.js |
| Mobile-specific | src/__tests__/mobile/<name>.spec.js |
Do not add new E2E tests to test/e2e/ — that directory is no longer the home for spec files.
Gold standard test organization
See src/plugins/pagination/__tests__/ for reference - separate dirs for options, methods, hooks, and strategies.
Common mistakes
- Forgetting
asynconit()callbacks. - Using the
hot().form (hot().countCols(),hot().alter(...)) instead of the bare global (countCols(),await alter(...)). - Importing helpers manually (they are globals).
- Not testing the
updateSettings()cycle. - Missing edge cases: large datasets, coordinate boundaries, enable/disable cycles.
- Not testing both keyboard navigation modes (spreadsheet + data grid).
- Trusting the spec count. Until the bridge reporter sanitized failed expectations (
test/helpers/jasmine-bridge-reporter.js, shared with the Walkontable runner), a failing spec whoseexpectedoractualwas a cyclic object (toBe(window),toEqual([overlay, …])) could not cross the Puppeteer bridge and was dropped from the run:Running N specs.in--verbosemode,N-1 specs, 0 failuresat the end, exit code 0. The bridge now reports such a spec as a normal failure with the value described ([unserializable Window]); if a count ever comes up short again, compare theRunning N specs.line against the summary line withnpm run test:e2e -- --testPathPattern=<file> --verbose.
Reference handsontable/.ai/TESTING.md for full testing docs. Key files: test/helpers/common.js, test/helpers/mouseEvents.js, test/helpers/keyboardEvents.js.
Signals
- GitHub stars
- 22k
- Forks
- 3k
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
handsontable-e2e-testing- Source
- github.com/handsontable/handsontable