Skip to main content

Tables

A table is a grid's rows and columns in the words a test uses: find the row whose customer number is 12345 and whose status is Active, then act in that row. This page is the guide for anyone whose tests read or act in grids, tables and lists; the exact signatures are in api.md.

const main = app.window();
const customers = main.table("Customers");

const row = await customers
.row()
.where("Customer number").equals("12345")
.and("Status").equals("Active")
.find();

await row.getByColumn("Actions").getByRole("button", { name: "Edit" }).click();
await expectDesk(row.getByColumn("Status")).toHaveText("Active");

A table, its row query and a found row are all lazy: nothing is looked up until find(), all(), count() or an action on a row or cell runs, and a found row is a locator that resolves by the same conditions whenever it is used, so it is never stale.

Finding a table​

const main = app.window();
main.table("Customers"); // the grid or table with that accessible name
main.table({ root: main.getByAutomationId("customGrid") }); // any locator that holds the rows
main.getByRole("dialog", { name: "Invoice" }).table("Lines"); // a table inside another control
main.table({ name: "Orders", columns: ["Order number", "Customer", "Placed"] }); // with the column names the test knows

The name is matched like a getByRole name: a case-insensitive substring, or a RegExp. A table query also matches a control reported as a grid: a WPF DataGrid is one, a Windows Forms DataGridView the other, and they hold the same rows and cells. When a table and a grid share the name, the table is meant. Where no table or grid has the name, a list that holds column headers does: a list view in report mode (Win32, Delphi VCL, SWT, wxWidgets), whose items are its rows and their texts its cells. A list view of a class of its own (Delphi's TListView), whose items UI Automation reports as their first column alone, is read through its own window, every column of it. JavaFX reports each column header after the first as a row on the header's line; such rows are headers. An empty column header with nothing under it (Avalonia's filler past the last column) is no column. Given a root, the table is what that control holds, laid out by the first shape that fits (see below).

columns names the columns. They become the only names where() and getByColumn() accept, at compile time (Table<"Order number" | "Customer" | "Placed">) and at run time, so a typo fails before anything resolves rather than as a row that is never found. Without columns, any name goes, and it is checked against the headers the grid shows when the query resolves. columns may also say how each column is found, as a mapping (see below):

const main = app.window();
main.table({ name: "Orders", columns: { "Order number": { header: "No." } } }); // the grid heads it "No."

A table by what holds it​

const main = app.window();
main.table({ root: main.getByAutomationId("orderBoard") });

A root that is, or holds one, grid the tree reports is that grid, read as the tree reports it. Otherwise its rows are inferred from what it holds, by the first of these that fits:

What the root holdsLaid out as
a list whose items each hold a text per column (a list view in report mode)the items as rows, their texts as cells, named by the list's header
siblings of one role that repeat, each holding as many cells (two or more)those siblings as rows, the parts that bear text, a value or an area as cells; a sibling before them whose parts are texts, or a header the tree reports, names the columns
texts that line up in three or more columns under a header rowthe header row's texts as column headers, each later row as a row of cells
nothing but scroll bars, or nothing at alla grid laid out from the screen: from the lines it draws, else from its text (as a Delphi grid is)

A root that fits none fails to find a row with Laid out: not laid out: nothing in it repeats as rows; define its rows and cells, and a definition then says what the rows are. How the table was laid out is on the Laid out: line of every miss. A root over a Windows Forms grid or a Win32 list view keeps the helper's fast reads of those.

A table defined from locators​

When the rows are known by something no inference sees — a class name, an AutomationId pattern, a position — say what they are:

const main = app.window();
const orderBoard = main.table({
root: main.getByAutomationId("orderBoard"),
rows: (root) => root.getByClassName("OrderRow"),
cells: (row) => row.getByRole("text"),
header: (root) => root.getByClassName("HeaderRow").getByRole("text"),
columns: {
Placed: { index: 0 },
Customer: { index: 1 },
Warehouse: { header: "Whs" }, // headed "Whs" by the application
Open: { locator: (row) => row.getByRole("button", { name: "Open" }) },
},
});
const row = await orderBoard.row().where("Customer").equals("Acme Ltd").find();
await row.getByColumn("Open").click();
  • rows locates the rows inside the root, cells a row's cells (in column order) inside the row, and header the column headers' texts inside the root. Each is given what it locates from, and must locate from it: a definition that returns main.getBy… instead of root.getBy… is refused with a TypeError at once. Every locator works inside a definition: getByClassName, filter, nth, relative locators.

  • Without cells, a row's cells are its cells, else everything it holds.

  • columns as a list names the columns by place; as a mapping each column is { index } (its place among the cells), { locator } (what a locator finds in the row, wherever it sits) or { header } (the column the application heads with that text). Columns not named keep the header's text, or none.

  • Each column in a mapping may say where its cells take their text, with value: "checked" for a column of check boxes ("true"/"false"), "name" for a status shown as an icon with a name, "value" or "text". where(), read() and getByColumn() then see that text. It works on a grid the tree reports as well as on a table laid out from a definition:

    const main = app.window();
    const orders = main.table({ name: "Orders", columns: { Id: { header: "Id" }, Paid: { header: "Paid", value: "checked" } } });
    const unpaid = await orders.row().where("Paid").equals("false").find();
  • The minimum for a control that exposes nothing standard is root plus rows plus cells or a locator per column. Headers are optional: the column names then belong to the test.

  • pageDown locates what shows the next page of rows (a "Next" button), for a table that pages by a control of its own rather than by scrolling.

The definition is evaluated once, when the table is made, and kept as the steps it located with, so it is described as written in records and errors:

table({ root: getByAutomationId("orderBoard"), rows: getByClassName("OrderRow"), columns: { "Placed": 0, "Warehouse": header "Whs" } })
>> getByRole("row").filter({ has: getByColumn("Warehouse").filter({ hasText: "A2", exact: true }) })

At match time the root is laid out as a grid of a header and rows of cells: the rows and cells keep their identity, so acting on a cell acts on the control the definition found, and what a row holds besides its cells stays in it (row.getByRole("button")). A row that lacks a cell keeps its other cells in their columns. Such a table is observed through the tree, the root alone once paging has found it, and is read off the screen only for cells whose text the tree does not report.

Lists​

A list is rows without columns: the same skeleton, for list boxes, ARIA listboxes and anything whose items are told apart by what they say.

const main = app.window();
const templates = main.list("Templates");
const item = await templates.item().named("Template 150").find(); // pages a virtualised list to it
await item.dblclick();
await templates.item().containing("urgent").count();
await templates.item().matches(/^Template 1\d\d$/).all();
console.log(await templates.read()); // the items shown, as text

const cards = main.list({ root: main.getByAutomationId("board"), items: (root) => root.getByClassName("CardView") });

named(text) is the item's whole text ({ exact: false } for a case-insensitive substring), containing(text) a substring and matches(re) a pattern. find(), all(), count(), isEmpty(), first(), nth() and last() behave as they do on a table's rows:

  • read({ scroll: true }) reads a virtualised list to its end, page by page, with maxRows (items) and maxPages to stop early. Pages are joined by the items' places where the backend knows them (a list view read through the helper), else by what they show, which the record then says: two items with the same text at a page's edge may be read as one.
  • count({ pages: "all" }), all({ pages: "all" }) and find({ strict: "all-pages" }) look at every page of the list, as a table's rows do: all gives each matching item as a locator by its whole text, which an action pages the list back to.
  • expectDesk(list) asserts on the items shown, retrying: toHaveItems([...]) (these, in order), toContainItems([...]) (these among others, each its own), toHaveItemCount(n) and toBeEmpty(), each with .not and pages: "all" for the whole list.
  • expectDesk(list.item().containing("Shipping")).toHaveCount(2) waits for a number of matching items.
const main = app.window();
await expectDesk(main.list("Templates")).toContainItems(["Quotation", /^Shipping/]);
const everything = await main.list("Templates").read({ scroll: true, maxRows: 1000 });

Rows by what their cells hold​

const rows = customers.row();
rows.where("Status").equals("Active"); // the cell's whole text (whitespace-normalised, case-sensitive)
rows.where("Country").contains("nether"); // a case-insensitive substring
rows.where("Customer number").matches(/^12/); // a regular expression
rows.where("Status").equals("Active").and("Country").equals("NL"); // every condition must hold
customers.row({ "Customer number": "12345", Status: "Active" }); // the same, from data: strings are equals, RegExps matches
orders.row().where("Total").greaterThan(1000); // the number the cell shows ("$1,250.50" is 1250.5)
orders.row().where("Total").between(100, 500); // both bounds included; also lessThan(n) and equals(n)
orders.row().where("Total").equals(1250, { locale: "nl-NL" }); // "1.250,00": the locale's separators
files.row().where("Selected").has((cell) => cell.getByRole("checkbox", { checked: true })); // what the cell holds

A condition looks at the cell under the named column: its name or value, or that of anything the cell holds (a text block, a combo box), so a WPF cell named by its text, a Windows Forms cell whose value is its text and a cell read off the screen are all matched the same way. Text read off the screen is compared with what readers confuse set aside (1/I/l, 0/O), as everywhere else. where and and are the same method; use whichever reads well.

A number condition (greaterThan, lessThan, between, equals(n)) reads the one number the cell shows: its group separators left out, its decimal separator taken as one, a currency sign, unit or percent sign around it set aside. A cell that shows no number, or more than one ("3 of 5", a date), does not match. The locale says how the cell writes numbers ("en-US" by default); a space of any width groups thousands where the locale groups them with one. has((cell) => …) matches a row by a control its cell holds, for a state rather than a text: the locator must start from the cell it is given. An expected row of an assertion takes a number too: toContainRows([{ Total: 1250.5 }]) compares the cell's number, read as en-US.

Resolving​

OperationWaitsPages a virtualised gridSeveral rows match
find()until one row matches, or the timeoutyes (scroll: false looks only at the rows shown)StrictModeViolationError: add a condition, or take first()
first().find(), nth(i).find(), last().find()the sameyespicks the one at that position, in document order
all()nonoone Row per match, in document order
all({ pages: "all" })noevery page, onceone Row per match in the whole grid, each found again by its cells
count()nonothe number
count({ pages: "all" })noevery page, oncethe number in the whole grid
find({ strict: "all-pages" })as find()yes, then every page onceStrictModeViolationError when another row of the whole grid matches

find() waits as an action waits: a grid that creates only the rows on screen (WPF, WinUI) is taken to its top and paged, one page per attempt, until the row exists or the view stops moving. A grid that creates only the columns in view (WPF's EnableColumnVirtualization) is first paged sideways to a column the query asks for and the grid does not show, right and then back left. See dotnet-apps.md for what a page costs. all() and count() observe once and never page or wait, like all() and count() on a locator: on a virtualised grid they see the rows it shows now; when the grid says how many it holds, the record of count() says "4 rows shown of 10000 the grid holds". To wait for a number of rows, assert on the query: expectDesk(query).toHaveCount(3), which asserts as its locator.

count({ pages: "all" }) takes the grid to its top and pages it to its end, as read({ scroll: true }) does, and counts each matching row once: by its place in the grid where the backend reads it, else by what it shows. find({ strict: "all-pages" }) finds the row as find() does, then looks at every page for another, and brings the row back into view. all({ pages: "all" }) gives every matching row of the grid as a Row for that row by every cell it showed under a header named once: an action on it pages the grid back to the row (a read, such as textContent(), sees it only where the grid shows it: scrollIntoView() it first), and two rows that show the same cells are one locator, which an action on it says (strict). Both take maxPages, and count a timeout for the paging. A grid that the backend reads whole at once (a Windows Forms grid or a list view through the helper, whose rows each carry their place) is not paged at all: its rows, however many, are counted from one read.

A row find() returns is a locator for the row, so everything a locator does applies to it: row.getByRole("button", { name: "Delete" }).click(), row.getByText("Overdue"), expectDesk(row).toBeVisible(). Its getByColumn(column) is the cell under that column. An action on a cell scrolls it into view, or pages the grid again if the view has moved.

Cells by their column​

const main = app.window();
row.getByColumn("Email"); // the row's cell under "Email"
customers.row().where("Id").equals("7").getByColumn("Email") // the same, from the query, without find()
customers.getByColumn("Status"); // every cell of the column, top to bottom
await customers.getByColumn("Total").allTextContents();
await customers.columns(); // the header texts, left to right
main.getByRole("row").first().getByColumn("Name") // on any locator

A column name is compared with the whole header text, whitespace-normalised and case-insensitively ("Customer\nnumber" reads as "Customer number"); { exact: true } makes it case-sensitive, and a RegExp is tested as given. So getByColumn("Name") is not also the "First name" column, and neither is getByRole("cell", { column: "Name" }), which follows the same rule (column: /name/i asks for both). Which header a cell sits under comes from the grid's tree: the header over the cell's place on screen, or, for a row reported off screen without bounds, the header at the same place among the row's cells. A header's text is read whitespace-normalised, so a header that says "Name " is the column "Name" in a record, an assertion and sortBy alike.

A query's getByColumn is the cell in the row the query finds, lazy and strict as the row is: several matching rows fail an action on it.

Editing a cell​

A cell the grid lets a user edit is filled as a field is: fill sets its value through its Value pattern where it has one, else types into it. Most grids commit an edit when the cell is left, so end it as a user does:

const total = customers.row().where("Id").equals("7").getByColumn("Total");
await total.fill("35.50");
await total.press("Enter"); // or "Tab" to the next cell
await expectDesk(total).toHaveText("35.50");

A Windows Forms grid read through the helper sets the cell's value through its MSAA object, and the row it acts on is the row read.

Reading a table​

const rows = await customers.read();
// [{ "Customer number": "12345", Name: "Acme Ltd", Status: "Active", Country: "NL" }, …]
expect(rows).toEqual(expectedCustomers);
await writeFile("customers.json", JSON.stringify(rows, null, 2));

const everything = await orders.read({ scroll: true, timeout: 120_000 }); // the whole grid, page by page

read() is the table as data: one record per row, each cell's text keyed by its column's header, from one observation of the rows the grid shows. It waits for the table to exist first, so a table that never appears is a TimeoutError, never an empty reading; then it reads once and never pages, like all() and count(). A cell's text is its value, else its name, else what it holds (a text block, the captions of buttons). A cell under no named header is keyed by its position ("3"), and a column whose header repeats another's by that header and its rank ("Name (2)"), so nothing is silently merged. columns() gives the headers in order, once the table is there: it waits for the table as read() does, so an empty list means a table without headers, never one that has not appeared yet.

read({ scroll: true }) reads a virtualised grid whole: it takes the grid to its top (a grid that cannot be taken there is read from where it is, its first view included) and pages it to its end the way an action pages it (the mouse wheel in the foreground, the scroll pattern in the background, the scroll bar of a painted grid), reading after every page and joining the pages where they overlap, by the rows' texts. It costs one observation per page (see dotnet-apps.md), and when the end is not reached within timeout it fails rather than hand back part of the grid: raise timeout for a long grid, or prefer the application's own filter. Rows whose place in the grid the backend reads (Windows Forms grids and list views read through the helper) are joined by it, so two rows that show the same cells stay two. Other rows are joined by what they show, and the operation's record says so ("N rows at page edges counted once by what they show"): two different rows with the same cells at a page's edge cannot be told from one row seen twice. maxRows stops the read once that many rows are read, and maxPages after that many pages, both said in the record: read({ scroll: true, maxRows: 500 }).

Every read() is recorded as a read operation with the rows and columns read, and what paging did.

Asserting on a table​

await expectDesk(customers).toHaveRows([
{ "Customer number": "12345", Status: "Active" },
{ "Customer number": "12346", Status: /^(In)?active$/ },
]);
await expectDesk(customers).toContainRows([{ Name: "Acme Ltd" }]);
await expectDesk(customers).not.toContainRows([{ Status: "Deleted" }]);
await expectDesk(customers).toHaveRowCount(2);
await expectDesk(results).toBeEmpty();
await expectDesk(customers).toHaveColumns(["Customer number", "Name", "Status", /^Country/]);
await expectDesk(points).toBeSortedBy("Value", { descending: true });
await expectDesk(customers).toHaveColumnValues("Status", ["Active", "Active", /^In/]);

expectDesk(table) reads the rows the table shows, again and again, until the expectation holds or the timeout elapses, like every assertion. With { pages: "all" } each attempt reads the whole grid, page by page, as read({ scroll: true }) does (a grid the helper reads whole is read once an attempt), and maxPages bounds it: expectDesk(orders).toHaveRowCount(2_000, { pages: "all", timeout: 120_000 }). toHaveRows expects exactly these rows in this order; toContainRows expects each of them to match a row of its own, in any order, among any others. toHaveRowCount(n) expects that many rows, and toBeEmpty() none; both count the rows the grid shows unless pages: "all" asks for every page; when the grid holds more, a failure says "N rows shown of M the grid holds", as count()'s record does. row()...isEmpty() tells whether no row matches a query: it waits for the table (exactly one, so a table that is not there or a wrong name is a TimeoutError, never "empty"), not for its rows. An expected row names only the columns it cares about; a string is the cell's whole text (whitespace-normalised, case-sensitive; read off the screen, once what readers confuse is set aside) and a RegExp is tested as given. A failure lists the rows expected, the rows last read (in the columns named), and the first difference:

AssertionError
expectDesk(table("Customers")).toHaveRows
Expected: 2 rows:
1) { "Customer number": "12345", "Status": "Active" }
2) { "Customer number": "12346", "Status": /^(In)?active$/ }
Actual: 2 rows (columns "Customer number", "Name", "Status", "Country"):
1) { "Customer number": "12345", "Status": "Inactive" }
2) { "Customer number": "12346", "Status": "Active" }
Last observed state: row 1: Status is "Inactive", expected "Active"

toHaveColumns reads the headers alone, left to right, each a whole text or a RegExp; none of the rows is read. toBeSortedBy tells an order as sortBy does (numbers as numbers, text in natural order, collated, or character code by character code, equal neighbours allowed) and needs two rows with a value under the column; a failure names the first two rows out of order. toHaveColumnValues is one column's cells in the rows' order, each a text, a RegExp or a number.

A column an assertion names that the grid does not have passes neither the assertion nor its negation: not.toContainRows([{ Stauts: "Deleted" }]) fails, saying the columns there are. With pages: "all" and maxPages, a read stopped before the grid's end decides only what its pages can (a row found, a count already passed, a row that differs); otherwise the assertion and its negation both fail, saying the read stopped at maxPages.

To wait for a number of matching rows, assert on the row query: expectDesk(table.row().where("Status").equals("Open")).toHaveCount(3).

When a row is not found​

The error says what was seen, so a query with several conditions says which one failed:

TimeoutError
Timeout 5000ms exceeded waiting for table("Customers").row().where("Customer number").equals("12345").and("Status").equals("Active") to exist.
Last observed state: not found; searched every page of grid "Customers" (paged grid "Customers" 5 times with its scroll pattern)
Table: table("Customers")
DesktopLocator: getByRole("table", { name: "Customers" }) >> getByRole("row").filter({ has: getByColumn("Customer number").filter({ hasText: "12345", exact: true }) }).filter({ has: getByColumn("Status").filter({ hasText: "Active", exact: true }) })
Laid out: from the tree
Columns: [0] Customer number [1] Name [2] Status [3] Country
Rows shown: 18 (paged grid "Customers" 5 times with its scroll pattern)
Conditions:
Customer number = "12345" → 1 row
Status = "Active" → 11 rows
Closest: the rows satisfying every condition but "Status" read "Inactive" there

Where the grid reports it through UI Automation's grid and table patterns (WPF, Windows Forms, WinUI, read through the helper), the error also says how many rows the grid holds beyond those shown (Rows shown: 18 of 2000 the grid holds) and, when columns are scrolled out of view, every column it holds (Columns the grid holds: …).

The counts come from the tree the last attempt observed, without observing again; when the grid's rows were not in it (a Windows Forms grid read through the helper, a grid laid out from the screen), each question is asked of the resolver once more, on the failure path only. Every find() is recorded in desktop.actions as a find operation, with the query as written as its locator.

A grid laid out from the screen also says how sure the reading was: Read from the screen: 1 of 9 cells read nothing, 1 line(s) below the minimum confidence 0.5 left out. Its cells count only the columns that show text in some row; a cell that read nothing is left empty, never guessed. The OCR resolver's record carries the same counts.

What a table compiles to​

The table API adds no way of resolving anything. Every backend reports a grid in one shape, a grid or table holding a header of columnheaders and rows of cells (see dotnet-apps.md), whether the grid is reported by UI Automation, read through the helper, laid out from the lines it draws and the text read in it, or from plain text that lines up under a header. A row query is those locators:

table("Customers").row().where("Status").equals("Active")
= getByRole("table", { name: "Customers" })
>> getByRole("row").filter({ has: getByColumn("Status").filter({ hasText: "Active", exact: true }) })

query.locator is that locator, for anything the table API does not offer. The same resolution, paging, strictness, records and diagnostics apply, and the fast paths stay in use: a Windows Forms grid is still read through the helper, a Win32 list view through its messages. A table defined from locators, or by its root, starts with a table(…) step instead, which lays the root out as that same shape; every step after it is one of the above.

Some backends report a grid in other words, and are given the same shape: a Win32 list view in report mode, read through its window, reports its column headers as a list view's header does; a Java JTable's cells, which the Java Access Bridge reports one after another, are grouped into rows of as many cells as it has column headers.

Tables in snapshots and for agents​

A snapshot gives every grid's columns and every row's cells, aligned to the columns by the rule a locator pairs a cell with its header by (an empty string where a row has no cell under a header). A table defined from locators is laid out in a snapshot it is given:

const { controls } = await app.snapshot({ tables: [orderBoard] });

The agent layer shows a row as one line of its cells, reads a grid page by page with desktop_read_table (spart-desk-cli table <ref>), and takes table definitions in the locator string grammar, per snapshot or for the session (see agentic-mcp.md). A cell's suggested locator, and so a recorded read of one, is its column in its row: getByRole("row").filter({ hasText: "Order 42", exact: true }).getByColumn("Email").

Sorting, header rows and rows that nest​

table.sortBy(column, { descending? }) sorts the grid as a user does: it clicks the column's header with the real mouse until the rows shown are in that order, at most three times (a header that sorts one way, the other, then not at all), and leaves rows already in that order alone. It reads only the rows the grid shows, and needs two of them to tell an order. The order is read from the rows: numbers as numbers, text in natural order, collated, or character code by character code, the ways grids sort text; a value with letters in it ("P4991") is text. A column that does not come into order is a not_supported error. Filtering is the application's own: a filter row, a search box or a dialog, reached with ordinary locators.

A grid with a header row over its column headers (<th colspan="2"> over two headers) pairs each cell with the header right over it, the narrowest one, and names a column whose header text repeats under another group by both: "Sales › Q1". A condition on the leaf alone (where("Q1")) matches the cells of every group that holds one. Chromium's second header row, which it reports as a row of cells, is taken for headers when a header reaches over it or spans its cells.

An ARIA tree grid's rows, which Chromium reports as tree items, are the table's rows in the order the tree shows them, each with its level; row.expand() and row.collapse() open and close one.

Notes​

  • Strictness, count(), read() and the assertions see one view of a virtualised grid by default: two rows on different pages that both satisfy the conditions are not both seen, and find() returns the first page's. read({ scroll: true }), count({ pages: "all" }), all({ pages: "all" }), find({ strict: "all-pages" }) and the assertions with pages: "all" look at the whole grid, a page per observation.
  • find() reaches a record thousands of rows down in a few looks when the rows are in order by the column it asks for (where("Id").equals("7842") on a grid sorted by Id): the view is set to where the row should be and adjusted by what shows, as a user drags the scroll bar. A grid that is not in order by that column is paged a view at a time from its top, which takes a page per attempt: prefer the application's own search or filter there, or ask for the row by a column the grid is sorted by.
  • A XAML grid of more than 200 rows (the Community Toolkit's DataGrid in a WinUI 3 or UWP application) is read by the rows in its view, through its grid pattern: listing every row of a very large grid is slow for the application, so scope a locator into the grid and let an action page it, or use the application's own filter. Its columns are those shown whole, hidden and moved ones under their own headers; a column wider than the view is not read. A look that reads no row of a grid holding rows says why, and no assertion passes on it. Rows of different heights are followed from the grid's last read, so that a paged read loses none; a view moved by hand past them, with no read in between, may be moved by part of a view by the first read.
  • A Java JTable's rows and cells are placed from the table's height shared among its rows, and its columns from even shares of its width where Swing's header entries give no place of their own: a column resized by hand is placed where an even share would be. A row out of view is brought into view by the table's scroll bar, a plain click selects it through its first cell, and a double or right click is real input on that cell. The bridge reads a table's first 1,000 cells: the rows after those are out of reach. One that shows no header is grouped into rows by the column count its AccessibleTable reports, its cells keyed by place.
  • A table that exposes neither rows and cells, lines, nor text that lines up under a header, and whose root holds something, is not inferred: define its rows and cells.
  • Filtering controls are never operated: a query matches the current state of the grid. sortBy reads the order from the rows shown, not from a sort glyph.
  • A cell's text comes from the source its column names (value: "text", the default, else "value", "name" or "checked"): a column of check boxes defined with value: "checked" reads "true" or "false", so where("Paid").equals("true") matches it. A state can also be matched through the cell's own control, where("Paid").has((cell) => cell.getByRole("checkbox", { checked: true })), or read with getByColumn("Paid").getByRole("checkbox") and toBeChecked().
  • A table whose rows nest is read as one row per line, in the order the tree shows them: a record holds no level, and a row's children are not told from the rows after it.