Handsontable E2E Testing Guide (legacy Jasmine/Puppeteer — frozen)

SkillWeb & browsing

Use 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.

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-e2e skill and put them in tests/e2e/. This guide is for maintaining existing *.spec.js files. The presence gate blocks a newly added *.spec.js, and appending three or more new it blocks to a modified frozen spec draws its non-blocking frozen-suite-growth advisory — 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 be async.
  • 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() (see handsontable/.ai/TESTING.md)
  • Iframe doc.write theme CSS: getE2eThemeStylesheetLinkTagsHtml() (all themes), getE2eThemeStylesheetLinkTagHtml(key), getE2eNormalizeStylesheetLinkTagHtml() - from common.js; theme list is E2E_REGISTERED_THEME_KEYS in themeLayoutFromTokens.js, auto-discovered from src/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) or expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1)

Viewport helpers (all globals from common.js):

  • expectedVisibleRows(containerHeight, colHeaderRows = 1) - number of fully visible data rows
  • expectedLastFullyVisibleRow(containerHeight, colHeaderRows = 1) - 0-based index of the last fully visible row
  • containerHeightForRows(rowCount, colHeaderRows = 1) - height that guarantees exactly rowCount fully visible rows (prefer this over hardcoded height: 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() from test/helpers/mouseEvents.js
  • Keyboard: keyDown(), keyUp(), keyDownUp() from test/helpers/keyboardEvents.js
  • Touch: triggerTouchEvent(type, target), simulateTouch(target) from test/helpers/common.js
    • triggerTouchEvent('touchstart', element) / triggerTouchEvent('touchend', element) — dispatches a single touch event
    • simulateTouch(element) — full Android sequence: touchstart → touchend → mousedown → mouseup → click (with preventDefault handling)
    • 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 any tbody tr count depend on how many rows the container shows, which varies per theme and per machine. Size the container with containerHeightForRows(n) or scrollViewportTo() the target into view before counting. An unpinned count is a per-theme coin flip.
  • waitUntil(condition, timeout) replaces sleep() and waitForNextAnimationFrames(). It is a spec global from test/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 (normalizeFrameCount caps it) and pads the rest of the request with 16 ms per frame — so it is sleep() in a different unit. Every sleep() call warns today (handsontable/no-fixed-sleep-in-spec runs at warn level so the existing debt surfaces without red-walling CI); a diff-scoped ratchet that fails a NEW sleep() 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; new it.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 .dump step (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 --theme is omitted: main)
  • Rebuild first: The E2E runner loads dist/handsontable.js. After changing src/**, run npm run build --prefix handsontable before 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.stringify omits keys whose value is undefined - a missing key in the output usually means the value was undefined, not that the line is stale. Use String(value) when you need to distinguish undefined/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 testedDirectory
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
Pluginsrc/plugins/{name}/__tests__/*.spec.js
Keyboard shortcutssrc/shortcuts/__tests__/keyboardShortcuts/<name>.spec.js
i18nsrc/i18n/__tests__/<name>.spec.js
Mobile-specificsrc/__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 async on it() 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 whose expected or actual was a cyclic object (toBe(window), toEqual([overlay, …])) could not cross the Puppeteer bridge and was dropped from the run: Running N specs. in --verbose mode, N-1 specs, 0 failures at 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 the Running N specs. line against the summary line with npm 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