You have a grid with eight thousand rows. You open dev tools, expand the grid's DOM node, and count the <div role="row"> elements. There are twenty-three. Not eight thousand — twenty-three. Everything below the fold does not exist in the document, and everything above the fold that you scrolled past has already been recycled back out.
This is server-side row virtualization, and it is the correct way to render a large grid. Shipping eight thousand rows of markup to the browser would jank the whole page. So a Wisej DataGridView keeps a rendered window of whatever fits in the viewport, and asks the server for more row blocks as you scroll. The DOM is a viewport, not the dataset.
Which means every test that reaches for getByRole('row') on that grid is reading a lie.
The failure has two shapes
The first shape is the obvious miss. You want to assert that a customer named "Alvarez, Maria" appears in the orders grid. Her order is row 3,182 of 6,400. Your test does expect(page.getByRole('row', { name: /Alvarez, Maria/ })).toBeVisible() and it times out, red, even though she is unambiguously in the data. Her row was never rendered, so Playwright waited for an element that the framework had no reason to mount.
The naive fix makes it worse: scroll and re-check, scroll and re-check. Now the test is a loop of pixel scrolls and screenshots, slow and non-deterministic, and it still fails when the block she lives in hasn't been fetched yet. An agent left to brute-force this will burn dozens of turns doing exactly that.
The second shape is nastier because it goes green. You apply a filter — "show only Delayed orders" — and immediately assert the first result. The grid's client model has already cleared its old rows, but the refresh round trip to the server is still in flight, so the model is handing back placeholder values for blocks it hasn't received. Your assertion reads an empty string or a stale row and either fails with the maddening "filters set but the grid shows 0 rows" or, worse, passes against the wrong data. That second outcome is a false green — the most dangerous kind of flake, because it hides real breakage behind a passing bar.
Both shapes have the same root cause. You are treating the DOM as the source of truth when the source of truth is the grid's backing table model. The rendered rows are a projection of that model, and a lossy one.
Stop scraping. Read the model.
The fix is to talk to the model directly — get the true row count, search the full server-side set forcing each block fetch as you go, and await the refresh round trip before you read anything. Every virtualized-grid framework has a backing model like this; the difference is whether your test harness can reach it. With the TestVibe Wisej.NET plugin enabled on a project, generated tests can, through a set of page.wisej_net helpers that read values straight off the grid's table model instead of scraping the viewport.
Start by asking the grid what it actually contains:
const info = await page.wisej_net.gridInfo({ id: "grdOrders" });
// { rowCount: 6400, virtualized: true,
// columns: [{ name: "Customer", ... }, { name: "Status", ... }], ... }
gridInfo returns the server-model row count, the column schema (the names you'll address cells by), the sort and filter state, and the current viewport range. The moment virtualized: true comes back, you know the DOM is undercounting and you stop trusting getByRole('row') for anything about size or membership.
To find a row anywhere in the set — not just the rendered window — hand the model your criteria and let it walk the blocks:
const hit = await page.wisej_net.gridFindRow({
id: "grdOrders",
criteria: { Customer: "Alvarez, Maria", Status: "Delayed" },
});
// hit.found === true, hit.row === 3182 (scrolled into view)
gridFindRow iterates the full virtualized row set block by block, forcing and awaiting each server fetch, until a row matches all the column criteria (AND across columns). It scrolls the match into view and returns its model index. No scroll loop, no screenshots, no guessing which block she's in.
From there you act on the located row with trusted framework events, not synthetic DOM clicks:
await page.wisej_net.gridDoubleClickCell({ id: "grdOrders", row: hit.row });
// trusted double-click → the edit dialog opens
gridDoubleClickCell fires the real double-click gesture the grid listens for — the open-for-edit interaction — on the row you found. Siblings for other cases: gridSelectRow to select before a bulk action, and gridRowAction({ button: "Delete" }) to click a row-level action button by its caption. All of them accept the same { row } or { criteria } targeting, so you rarely juggle raw indices by hand.
The refresh race is a real wait, so wait on it
The filter-shows-zero-rows problem is not a timing guess you paper over with a sleep. It's a concrete async round trip with an observable completion, so wait for that completion the same way you'd assert on a network response settling rather than a fixed duration:
// Apply the filter through the UI, then:
await page.wisej_net.gridWaitForRows({ id: "grdOrders", min: 1 });
// or, wait for a specific row to materialize:
await page.wisej_net.gridWaitForRows({
id: "grdOrders",
criteria: { Status: "Delayed" },
});
gridWaitForRows awaits the refresh round trip until the grid reports at least min rows — and, when you pass criteria, until a matching row actually lands in the loaded prefix. "Zero rows so far" resolves honestly into "here they are" or a real timeout, instead of an assertion that fires into a half-loaded model. Put this after every filter, sort, or reload and the classic false failure disappears.
To read exact values once you're past the race, gridCellValue fetches a single cell (forcing that row's block first) and gridRows({ start, count, columns }) materializes a range. Both await the fetch before returning, so you never read a placeholder:
const status = await page.wisej_net.gridCellValue({
id: "grdOrders", row: 3182, column: "Status",
});
// { value: "Delayed", ... } — from the model, not the DOM
This trap is not Wisej-specific
Server-side virtualization is a general technique, and so is the bug. Point a naive test at an AG Grid in server-row-model mode, a DevExtreme DataGrid with remote operations, or a react-window list backed by a paged API, and you'll hit the identical wall: the viewport holds twenty rows, membership assertions miss everything below the fold, and a filter asserted too early reads stale or empty state. The general discipline is the same everywhere — don't assert on rendered-row counts, and don't read a value until the fetch that produces it has landed.
What differs is how much of that discipline your harness enforces for you. With a plain Playwright suite against a virtualized grid, you're hand-writing scroll-until-mounted loops and picking network waypoints to wait on — correct, but fiddly, and easy to get subtly wrong across dozens of grid flows. Because TestVibe is itself built on Wisej.NET, the Wisej.NET plugin ships the model-reading path as first-class helpers, and generation carries a per-control strategy map so a grid interaction is authored against the model from the start rather than reconstructed from the DOM. The same plugin covers the rest of the control surface — combo boxes, trees, MDI tabs — that has the identical "hidden state the DOM doesn't show" property.
The mental model to keep: in a virtualized grid, the DOM is a camera, not a database. It shows you what's on screen right now. When your test needs to know what's in the grid — not what's currently painted — ask the thing that actually knows.
Test your grids against their model
If your app puts thousands of rows behind a virtualized grid, your tests deserve to see all of them. TestVibe generates Playwright specs from a plain-language description of the behavior, verifies each one by running it in an isolated cloud session before you ever see it, and — with the Wisej.NET plugin enabled — reads grid values straight off the backing model so a row far below the viewport is as reachable as the first one. Get early access and point it at your grid.