developer guide
Testing Your Data Grid: Playwright and Cypress Helpers
A small package of Playwright and Cypress helpers for end-to-end tests of an app built on Lattice Grid. Find a cell, read a row, sort a column, filter, and wait until the grid has settled, without touching a class name.
Why a test should not read the markup
A test that finds a cell by a class name or a stack of nested elements breaks the day the grid is
restyled, even though nothing about the grid's behaviour changed. The helpers read only the ARIA
roles and the data-* attributes the grid renders as its public DOM contract. No helper
uses a grid class name, and the package's own test run fails if one does.
The grid needs no option set for the helpers to work: point them at the element you passed to
createGrid. Gestures are real input, not synthetic DOM events. With Playwright they use
its mouse and keyboard; with Cypress they send mouse and key events through the browser's DevTools
Protocol, so Cypress needs a Chromium-family browser (Chrome, Edge or Electron).
Install
npm i -D @toclocoinc/lattice-grid-testing
There are no runtime dependencies. @playwright/test and cypress are
optional peers: install whichever runner you use.
A worked example in Playwright
Point grid at a Playwright locator for the grid's container or its
role="grid" element. Every locator it returns is a normal Playwright
Locator, so expect and auto-waiting work as usual.
import { test, expect } from '@playwright/test';
import { grid } from '@toclocoinc/lattice-grid-testing/playwright';
test('the orders grid sorts and filters', async ({ page }) => {
await page.goto('http://localhost:3000/orders');
const g = grid(page.locator('#orders')); // the element you passed to createGrid
await g.waitForIdle();
// locate a cell by column name and row index, and read it
await expect(g.cell('city', 0)).toBeVisible();
expect(await g.readCell('city', 0)).not.toBe('');
// sort the column, then wait until the grid has settled
await g.sort('city');
expect(await g.sortState('city')).toBe('ascending');
await g.waitForIdle();
expect(await g.readCell('city', 0)).toBe('Accra');
// filter a column
await g.filter('city', 'Accra');
expect(await g.rowCount()).toBeGreaterThan(0);
await g.filter('city', ''); // an empty value clears it
});
The same test in Cypress
It reads the same apart from the import and the selector string. Locators and readers are Cypress
chains, so .should(...) retries on them. Gestures need a Chromium-family browser:
Chrome, Edge or the bundled Electron.
import { grid } from '@toclocoinc/lattice-grid-testing/cypress';
it('the orders grid sorts and filters', () => {
cy.visit('http://localhost:3000/orders');
const g = grid('#orders'); // the selector of your grid element
g.waitForIdle();
// locate a cell by column name and row index, and read it
g.cell('city', 0).should('be.visible');
g.readCell('city', 0).should('not.equal', '');
// sort the column, then wait until the grid has settled
g.sort('city');
g.sortState('city').should('equal', 'ascending');
g.waitForIdle();
g.readCell('city', 0).should('equal', 'Accra');
// filter a column
g.filter('city', 'Accra');
g.rowCount().should('be.greaterThan', 0);
g.filter('city', ''); // an empty value clears it
});
The calls
grid(root) takes the grid's container or its role="grid" element: a
Playwright Locator, a Cypress selector string, or a DOM element for the core.
| Call | What it does |
|---|---|
header(column) | Locator for a column heading, by the column's field. |
cell(column, rowIndex) | Locator for a rendered cell. rowIndex is the zero-based position in the current sorted, filtered view. |
row(rowIndex) | Locator for a rendered data row. |
scrollToRow(rowIndex) | Scrolls a row into the grid's rendered window (rows are virtualised). |
rowCount() | Rows in the current view, rendered or not. |
readCell(column, rowIndex) | The cell's text, scrolling to it first. |
readRow(rowIndex) | { field: text } for every rendered column of the row, scrolling to it first. |
sortState(column) | 'ascending', 'descending' or 'none'. |
sort(column, direction = 'ascending') | Clicks the heading until the column sorts that way. |
filter(column, value) | Types value into the column's heading filter; '' clears it. |
selectRange(from, to) | Drags the mouse from cell { column, row } to cell { column, row }. |
editCell(column, rowIndex, value) | Double-clicks the cell, replaces its text, commits with Enter. The column must be editable in your app. |
isIdle() | Whether the grid shows no pending work now. |
waitForIdle({ timeout, quietFrames }) | Resolves once the grid has settled (below). |
The other views have a factory each. All of them offer item(key),
items(), itemKeys(), itemCount() and
waitForIdle().
| Factory | Root | Items keyed by | Extra |
|---|---|---|---|
list(root) | role="grid" | row key (data-key) | |
dataView(root) | role="grid" | item key (data-key) | |
kanban(root) | role="group" board | card key (data-key) | lane(columnId) |
gantt(root) | role="img" chart | task id (data-task) | |
calendar(root) | role="grid" | event key (data-key) | day('YYYY-MM-DD') |
A few of the further calls, and a board and a calendar, in Playwright:
await g.readRow(41); // { name: '…', city: '…', … }
await g.editCell('name', 2, 'Renamed'); // the column must be editable in your app
await g.selectRange({ column: 'name', row: 1 }, { column: 'qty', row: 3 });
await g.scrollToRow(150); // rows are virtualised
import { kanban, calendar } from '@toclocoinc/lattice-grid-testing/playwright';
const board = kanban(page.locator('#board'));
await expect(board.item(42)).toHaveAttribute('data-column', 'done');
const cal = calendar(page.locator('#calendar'));
await expect(cal.day('2026-03-18').locator('[data-key="e2"]')).toHaveCount(1);
The package root, also importable as /core, holds the part that needs no runner: the CSS
selectors, the page-side readers, and the same calls over live DOM elements for a test that runs
inside the page.
When the grid has settled
waitForIdle resolves when, for quietFrames animation frames in a row
(default 2), nothing under the root has changed and nothing carries the grid's public busy signals
(aria-busy="true", data-loading="true"), and the component has mounted. It
rejects after timeout ms (default 10000), naming what was still pending. Every gesture
waits for it before returning.
Two known gaps
Two parts of the grid's DOM contract are not there yet, and the helpers say so rather than paper over it.
-
No reader for a selected range. The grid does not yet mark range-selected cells
with
aria-selected, soselectRangeperforms the gesture but nothing in this package can report which cells ended up selected. Assert on what the selection caused (an edit, a copy, a status line) until the grid carries the attribute. -
No busy signal while a source loads. The grid does not yet raise
aria-busy="true"while rows are on their way, sowaitForIdlerests on the quiet-frames rule and the mount check. A slow remote source can look idle between the request and the first row; give such a test arowCountexpectation rather than trusting idleness alone.
Both are tracked on the grid side. A release of the package that can read them will say so in its README.
Versioning
The package is versioned independently of the grid. Each release states, in its README, the grid version its contract tests ran against. It reads only the roles and attributes above and sets no grid option, so it asks nothing of your grid configuration.