Lattice Grid

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.

CallWhat 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().

FactoryRootItems keyed byExtra
list(root)role="grid"row key (data-key)
dataView(root)role="grid"item key (data-key)
kanban(root)role="group" boardcard key (data-key)lane(columnId)
gantt(root)role="img" charttask 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, so selectRange performs 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, so waitForIdle rests 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 a rowCount expectation 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.