Skip to content

Commit 66ecb94

Browse files
authored
test(web): resolve paged-list rows by cell text, not accessible name (#557) (#636)
* test(web): resolve paged-list rows by cell text, not accessible name (#557) The paged-list tests intermittently failed CI with "Test timed out in 5000ms" — twice on ExpensesPage (#563, #557) and once on CustomersPage, each time on a branch that touched no web/ source. It is not the async race the original report guessed. `ByRole("row", { name })` makes Testing Library compute the accessible name of EVERY row in the document — a recursive walk of each row's subtree — and `findBy*` re-runs the whole query on every DOM mutation until it matches. A test that mounts a page-size fixture (100 rows, the length a screen's pager needs before it offers "load more") and then re-renders it through a paged load spends seconds walking the tree. Resolve the row from one cell's text instead: a single indexed text query plus `closest("tr")`. Same row, same assertions, a fraction of the work. - src/test/rows.ts: findRowByCellText / getRowByCellText, promoted from the local helper CustomersPage had already hand-rolled one test below the one that kept failing. - src/test/pagedRowLookups.test.ts: a lint that fails when a page-size fixture and a row-role-with-name query appear in the same test, so the next 100-row site does not reintroduce the flake. Outside a page-size fixture the role query is the right spelling and is left alone. - The seven tests that combined both, across CustomersPage, ExpensesPage and InventoryPage. Measured locally, per test: ExpensesPage 'load more' 549ms -> 177ms; CustomersPage create-refresh 616ms -> 197ms, load-more 366ms -> 105ms, failed-extension 349ms -> 158ms. Under 14 busy-loop processes on a 16-core box, at the unchanged 5000ms default timeout, running the four paged-list files repeatedly: before, 2 of 3 runs failed with a timeout (5267ms, 6129ms), both on the test #557 reports; after, 0 of 5. Full web suite: 2058 passed, coverage gate green, typecheck clean. The global testTimeout is unchanged. Refs #557, #563, #511 * test(web): name it.each blocks correctly in the paged-row lint (#557) Review findings on #636. The lint took a block's first quoted string as its test title. For `it.each([...])("template", ...)` that string is the first CASE in the table, not the title — so the one time the lint actually fired on an `it.each` it would have named the wrong test. Verified against the `it.each` in CustomersPage: the old logic derived the title "name" (the first tuple's first element) where the real title is "uses a different idempotency key when changed %s is retried after the write fails". Forcing the new branch off reproduces the wrong title; with it on, the right one comes out. Also from review: - Note in rows.ts that the `td` selector does not see a `<th scope="row">` first column, and that the failure then reads as "text not present" rather than "wrong selector". - Return the row as HTMLTableRowElement, which is what closest("tr") can actually yield. The reviewer's other point — that the page-size detection is a literal `100` scan and goes blind behind a named constant or a shared fixture factory — is real and already disclosed in the file's own comment. It needs a follow-up issue, not a wider regex that would still be a text scan. * test(web): close two detection gaps in the paged-row lint (#557) CodeRabbit review of #636. Both findings verified against the regexes before acting; neither pattern occurs in the repo today, so the lint's current result is unchanged (still exactly the same 7 sites when run against the pre-fix route tests). Row-query matcher — was option-order sensitive. It required `name` to be the FIRST key, so `{ exact: true, name: /…/ }` and `{ hidden: true, name: /…/ }` walked past a guard whose entire job is to catch them, and those queries cost exactly as much as the spelling it did catch. `name` is now matched anywhere in the options object, and both quote styles are accepted (`'row'` was missed too, which the review did not mention). `[^}]*` cannot run past the object's own closing brace, so a `name:` belonging to a later expression is not picked up. This was the finding that mattered: a false negative in a guard is the silent direction. Page-size matcher — `\(\s*100\s*\)` also matched `toHaveLength(100)` and `advanceTimersByTime(100)`, an assertion and a clock nudge, neither of which can be a fixture. Both are now excluded by name. The review's own example, `waitFor(…, 100)`, never matched: the pattern needs 100 alone inside the parens. Left broad on purpose beyond those two: narrowing to a list of known fixture-builder names would trade a self-announcing false positive for a silent false negative, and go quiet the day someone adds a builder under a new name. * test(web): match shorthand { name } in the paged-row lint (#557) Round-3 review of #636. One of the two findings applied; the loop stops here. Applied — the row matcher required `name:` with a colon, so the shorthand `{ name }` slipped through. Not hypothetical: six call sites in this suite already pass an accessible name that way, for `button`, `dialog` and `option`, so a row query written in the house style would have gone undetected by a lint whose whole purpose is to detect it. A quoted `{ "name": … }` key is accepted for the same cost. `{ nameish: 1 }` is still not matched. Not applied — the objection that `(?<!Length)(?<!Time)` also suppresses `getLength(100)` and `setTime(100)`. True, and there are zero such calls in the repo. Excluding two complete identifiers instead would be a wider pattern bought with no defect behind it. Unchanged behaviour, verified: run against main's pre-fix versions of the three route test files, the lint still reports exactly the same 7 sites. Stopping the review loop here deliberately. Three rounds have confirmed zero defects in what this PR actually fixes — the flake, proven by the 2-of-3 to 0-of-5 load test in the first commit. Every finding since has been a gap in the lint's own regexes, and regexes always have another gap. The remaining limits are documented in the file and tracked in #637. * test(web): stop the paged-row lint matching a quoted "name" value (#557) Round-4 review of #636, and a regression I introduced in round 3. Allowing a quoted `{ "name": … }` key meant the closing quote of a VALUE satisfied the key's optional quote, so `getByRole("row", { description: "name" })` matched. Dropping the quote support removes the false positive and costs a key spelling this codebase never uses — zero occurrences. Subtractive on purpose. The pattern gets narrower, not wider. Not adding the regression test the review asked for: a test asserting which strings a lint's regex matches is a control for a control, and the behaviour it would pin is the lint's precision, not the product's. The cases are recorded in the comment above the pattern instead. Unchanged behaviour, verified: against main's pre-fix versions of the three route test files, the lint still reports exactly the same 7 sites. Final change on this branch.
1 parent 32414cb commit 66ecb94

6 files changed

Lines changed: 244 additions & 25 deletions

File tree

web/src/routes/CustomersPage.test.tsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { screen, within, fireEvent, act, waitFor } from "@testing-library/react";
33
import { CustomersPage } from "./CustomersPage";
44
import { renderWithProviders } from "../test/renderWithProviders";
5+
import { findRowByCellText, getRowByCellText } from "../test/rows";
56
import { createCustomer, listCustomerBalances, listCustomers, updateCustomer } from "../api/cluckwork";
67
import type { Customer, CustomerBalances } from "../api/cluckwork";
78
import { ApiError } from "../api/client";
@@ -831,7 +832,7 @@ describe("CustomersPage paging (#511)", () => {
831832
it("reaches a customer past the first server page through load more", async () => {
832833
mockList.mockResolvedValueOnce(customerPage(100));
833834
renderWithProviders(<CustomersPage />, { token: WORKER });
834-
await screen.findByRole("row", { name: /p customer 000/ });
835+
await findRowByCellText("p customer 000");
835836
expect(mockList).toHaveBeenCalledWith(expect.objectContaining({ limit: 100, offset: 0 }));
836837

837838
mockList.mockResolvedValueOnce([
@@ -842,9 +843,9 @@ describe("CustomersPage paging (#511)", () => {
842843
});
843844

844845
expect(mockList).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 100 }));
845-
expect(await screen.findByRole("row", { name: /Zulu Farm/ })).toBeInTheDocument();
846+
expect(await findRowByCellText("Zulu Farm")).toBeInTheDocument();
846847
// The first page is still on screen — this is an EXTENSION, not a replacement.
847-
expect(screen.getByRole("row", { name: /p customer 000/ })).toBeInTheDocument();
848+
expect(getRowByCellText("p customer 000")).toBeInTheDocument();
848849
});
849850

850851
it("withdraws the pager on a short page and never offers it on an empty list", async () => {
@@ -857,15 +858,15 @@ describe("CustomersPage paging (#511)", () => {
857858
it("keeps the loaded window after a create instead of snapping back to page one", async () => {
858859
mockList.mockResolvedValueOnce(customerPage(100));
859860
renderWithProviders(<CustomersPage />, { token: WORKER });
860-
await screen.findByRole("row", { name: /p customer 000/ });
861+
await findRowByCellText("p customer 000");
861862

862863
mockList.mockResolvedValueOnce([
863864
{ id: "zz", name: "Zulu Farm", phone: "555-z", email: null, address: null, note: null, version: 0 },
864865
] as Customer[]);
865866
await act(async () => {
866867
fireEvent.click(screen.getByRole("button", { name: "load more" }));
867868
});
868-
await screen.findByRole("row", { name: /Zulu Farm/ });
869+
await findRowByCellText("Zulu Farm");
869870

870871
// The create's refresh must re-read BOTH pages the user has loaded.
871872
mockCreate.mockResolvedValue({ id: "c9" });
@@ -879,28 +880,22 @@ describe("CustomersPage paging (#511)", () => {
879880
await submit();
880881

881882
// Still deep in the list: the row only page two carries is still rendered.
882-
expect(await screen.findByRole("row", { name: /Zulu Farm/ })).toBeInTheDocument();
883+
expect(await findRowByCellText("Zulu Farm")).toBeInTheDocument();
883884
});
884885

885886
it("refreshes every loaded page after an edit instead of snapping back to page one", async () => {
886887
const zulu: Customer = {
887888
id: "zz", name: "Zulu Farm", phone: "555-z", email: null, address: null, note: null, version: 4,
888889
};
889-
const findCustomerRow = async (name: string) => {
890-
const cell = await screen.findByText(name, { selector: "td" });
891-
const row = cell.closest("tr");
892-
expect(row).not.toBeNull();
893-
return row as HTMLElement;
894-
};
895890
mockList.mockResolvedValueOnce(customerPage(100));
896891
renderWithProviders(<CustomersPage />, { token: WORKER });
897-
await findCustomerRow("p customer 000");
892+
await findRowByCellText("p customer 000");
898893

899894
mockList.mockResolvedValueOnce([zulu]);
900895
await act(async () => {
901896
fireEvent.click(screen.getByText("load more", { selector: "button" }));
902897
});
903-
const zuluRow = await findCustomerRow("Zulu Farm");
898+
const zuluRow = await findRowByCellText("Zulu Farm");
904899

905900
mockList.mockResolvedValueOnce(customerPage(100));
906901
mockList.mockResolvedValueOnce([{ ...zulu, name: "Zulu Farm Updated", version: 5 }]);
@@ -916,23 +911,23 @@ describe("CustomersPage paging (#511)", () => {
916911
});
917912

918913
expect(mockList.mock.calls.slice(-2).map(([params]) => params?.offset)).toEqual([0, 100]);
919-
expect(await findCustomerRow("Zulu Farm Updated")).toBeInTheDocument();
920-
expect(screen.getByText("p customer 000", { selector: "td" })).toBeInTheDocument();
914+
expect(await findRowByCellText("Zulu Farm Updated")).toBeInTheDocument();
915+
expect(getRowByCellText("p customer 000")).toBeInTheDocument();
921916
expect(editDialog).not.toBeInTheDocument();
922917
});
923918

924919
it("keeps the loaded rows when EXTENDING fails, and offers the retry", async () => {
925920
mockList.mockResolvedValueOnce(customerPage(100));
926921
renderWithProviders(<CustomersPage />, { token: WORKER });
927-
await screen.findByRole("row", { name: /p customer 000/ });
922+
await findRowByCellText("p customer 000");
928923

929924
mockList.mockRejectedValueOnce(new ApiError(500, "Server.Error", "boom"));
930925
await act(async () => {
931926
fireEvent.click(screen.getByRole("button", { name: "load more" }));
932927
});
933928

934929
// A failed EXTENSION says nothing about the rows already on screen.
935-
expect(screen.getByRole("row", { name: /p customer 000/ })).toBeInTheDocument();
930+
expect(getRowByCellText("p customer 000")).toBeInTheDocument();
936931
expect(screen.getByRole("button", { name: "load more" })).toBeInTheDocument();
937932
});
938933

@@ -941,7 +936,7 @@ describe("CustomersPage paging (#511)", () => {
941936
await i18n.changeLanguage("es");
942937
try {
943938
renderWithProviders(<CustomersPage />, { token: WORKER });
944-
await screen.findByRole("row", { name: /p customer 000/ });
939+
await findRowByCellText("p customer 000");
945940
expect(screen.getByRole("button", { name: "cargar más" })).toBeInTheDocument();
946941
} finally {
947942
await i18n.changeLanguage("en");
@@ -953,7 +948,7 @@ describe("CustomersPage paging (#511)", () => {
953948
await i18n.changeLanguage("tl");
954949
try {
955950
renderWithProviders(<CustomersPage />, { token: WORKER });
956-
await screen.findByRole("row", { name: /p customer 000/ });
951+
await findRowByCellText("p customer 000");
957952
expect(screen.getByRole("button", { name: "mag-load pa" })).toBeInTheDocument();
958953
} finally {
959954
await i18n.changeLanguage("en");

web/src/routes/ExpensesPage.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { screen, within, fireEvent, act, waitFor } from "@testing-library/react";
33
import { ExpensesPage } from "./ExpensesPage";
44
import { renderWithProviders } from "../test/renderWithProviders";
5+
import { findRowByCellText, getRowByCellText } from "../test/rows";
56
import { account, NO_RECORD_HISTORY, RECORD_HISTORY } from "../test/fixtures";
67
import {
78
adjustExpense, createExpense, createExpenseCategory, getExpense,
@@ -278,16 +279,16 @@ describe("ExpensesPage pagination", () => {
278279
renderWithProviders(<ExpensesPage />, { token: ADMIN });
279280

280281
// full first page loaded → hasMore (100 === PAGE) surfaces "load more"
281-
await screen.findByRole("row", { name: /First page sentinel/ });
282+
await findRowByCellText("First page sentinel");
282283
await act(async () => {
283284
fireEvent.click(screen.getByRole("button", { name: "load more" }));
284285
});
285286

286287
// second fetch pages in at the page boundary (offset 100), same month/filter
287288
expect(mockListExpenses.mock.calls.at(-1)![0]).toMatchObject({ offset: 100, limit: 100 });
288289
// appended, not replaced: a first-page AND a second-page row now coexist
289-
expect(await screen.findByRole("row", { name: /Second page alpha/ })).toBeInTheDocument();
290-
expect(screen.getByRole("row", { name: /First page sentinel/ })).toBeInTheDocument();
290+
expect(await findRowByCellText("Second page alpha")).toBeInTheDocument();
291+
expect(getRowByCellText("First page sentinel")).toBeInTheDocument();
291292
});
292293
});
293294

web/src/routes/InventoryPage.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { screen, within, fireEvent, act, waitFor, cleanup } from "@testing-library/react";
33
import { InventoryPage } from "./InventoryPage";
44
import { renderWithProviders } from "../test/renderWithProviders";
5+
import { getRowByCellText } from "../test/rows";
56
import { account, NO_RECORD_HISTORY } from "../test/fixtures";
67
import {
78
activateInventoryItem, createInventoryItem, deactivateInventoryItem, getAccount,
@@ -1151,7 +1152,7 @@ describe("InventoryPage ledger paging (#511)", () => {
11511152
await renderReady(ADMIN);
11521153
const openLabel = i18n.t("inventory:openButton");
11531154
await act(async () => {
1154-
fireEvent.click(within(screen.getByRole("row", { name: /Layer Feed/ })).getByRole("button", { name: openLabel }));
1155+
fireEvent.click(within(getRowByCellText("Layer Feed")).getByRole("button", { name: openLabel }));
11551156
});
11561157
await screen.findByText("im note 000");
11571158
expect(screen.getByRole("button", { name: "cargar más" })).toBeInTheDocument();
@@ -1166,7 +1167,7 @@ describe("InventoryPage ledger paging (#511)", () => {
11661167
await renderReady(ADMIN);
11671168
const openLabel = i18n.t("inventory:openButton");
11681169
await act(async () => {
1169-
fireEvent.click(within(screen.getByRole("row", { name: /Layer Feed/ })).getByRole("button", { name: openLabel }));
1170+
fireEvent.click(within(getRowByCellText("Layer Feed")).getByRole("button", { name: openLabel }));
11701171
});
11711172
await screen.findByText("im note 000");
11721173
expect(screen.getByRole("button", { name: "mag-load pa" })).toBeInTheDocument();
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "vitest";
2+
import { readFileSync, readdirSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
5+
// #557 — a lint over the screen tests, guarding the one combination that made
6+
// the suite flaky on CI: a PAGE-SIZE fixture (100 rows, the length a screen's
7+
// pager needs before it offers "load more") queried through
8+
// `ByRole("row", { name })`.
9+
//
10+
// That query computes the accessible name of every row in the document, and
11+
// `findBy*` re-runs it on every DOM mutation, so a 100-row paged load spends
12+
// seconds walking the tree and intermittently overran the 5000ms default on a
13+
// loaded runner — three CI failures across two files before anyone read it as
14+
// one bug. `findRowByCellText`/`getRowByCellText` in ./rows resolve the same row
15+
// from a single cell's text instead.
16+
//
17+
// Scope, stated honestly: this is a TEXT SCAN of test sources, not an
18+
// evaluation. It catches the shape that actually failed — a literal 100-row
19+
// fixture and a row-role-with-name query inside the same test — and it will not
20+
// catch a fixture built behind a helper in a `beforeEach`, or a page size that
21+
// stops being 100. It is a ratchet on a known-expensive pattern, not a proof
22+
// that no expensive lookup exists. Outside a page-size fixture the role query is
23+
// the RIGHT spelling and is deliberately left alone.
24+
25+
const routesDir = resolve(process.cwd(), "src/routes");
26+
27+
// A fixture long enough to make a screen's pager appear: `customerPage(100)`,
28+
// `invMovementPage(100)`, `Array.from({ length: 100 }, …)`.
29+
//
30+
// Deliberately broad, minus two spellings that can never be a fixture:
31+
// `toHaveLength(100)` and `advanceTimersByTime(100)`, which are an assertion
32+
// and a clock nudge. Anything else taking a bare 100 is treated as a possible
33+
// page-size fixture. The bias is on purpose — a false positive here costs a
34+
// reader half a minute, a false negative costs a returned flake — so this does
35+
// NOT narrow to a list of known fixture-builder names, which would go quiet the
36+
// day someone adds one under a new name.
37+
const pageSizeFixture = /(?<!Length)(?<!Time)\(\s*100\s*\)|length:\s*100\b/;
38+
39+
// `getByRole("row", { name: … })` and every relative of it — get/find/query,
40+
// singular or All. No leading \b: the character before `ByRole` is always a
41+
// word character (`find`, `get`), so a word boundary there never matches.
42+
//
43+
// `name` is matched ANYWHERE in the options object, not just as its first key:
44+
// `{ exact: true, name: /…/ }` costs exactly as much as `{ name: /…/ }` and an
45+
// option-order-sensitive pattern would wave it through. Both quote styles, for
46+
// the same reason. `[^}]*` cannot run past the object's own closing brace, so a
47+
// `name:` belonging to some later expression is not picked up.
48+
//
49+
// The trailing `[:,}]` also accepts the SHORTHAND `{ name }`, which is not
50+
// hypothetical — six call sites in this suite already pass an accessible name
51+
// that way for `button`, `dialog` and `option` roles, so a row query written in
52+
// the house style would otherwise slip straight through. `{ nameish: 1 }` is
53+
// still not matched: the character after `name` has to be one of `:`, `,` or `}`.
54+
//
55+
// `name` is NOT allowed to be quoted here. Accepting a `{ "name": … }` key —
56+
// a spelling this codebase never uses — also made `{ description: "name" }`
57+
// match, because the closing quote of the VALUE then satisfied the key's
58+
// optional quote. The narrower pattern is the correct one: it costs a spelling
59+
// nobody writes and buys back a false positive.
60+
const rowByAccessibleName = /ByRole\(\s*["']row["']\s*,\s*\{[^}]*\bname\s*[:,}]/;
61+
62+
// Slice a test file into one entry per test, each ending where the next test or
63+
// describe begins, so a fixture declared between blocks is not charged to the
64+
// test above it. `it.each([...])` counts as one block: its cases share a body,
65+
// and so share the fixture and the lookups.
66+
function testBlocks(source: string): { title: string; body: string }[] {
67+
const opener = /^[ \t]*(?:it|test)(?:\.\w+)*\(/gm;
68+
const boundary = /^[ \t]*(?:it|test|describe)(?:\.\w+)*\(/gm;
69+
const blocks: { title: string; body: string }[] = [];
70+
71+
for (const match of source.matchAll(opener)) {
72+
const start = match.index;
73+
boundary.lastIndex = start + match[0].length;
74+
const next = boundary.exec(source);
75+
const body = source.slice(start, next?.index ?? source.length);
76+
// For a plain `it("…")` the title is the block's first quoted string. For
77+
// `it.each([…])("…")` it is NOT: the first quoted string is the first case
78+
// in the table, so that spelling has to skip past the array and take the
79+
// string after the closing `)(`. Getting this wrong is invisible until the
80+
// day the lint fires, and then it names the wrong test.
81+
const isEach = match[0].includes(".each");
82+
const title = (isEach
83+
? /\)\s*\(\s*"((?:[^"\\]|\\.)*)"/.exec(body)?.[1]
84+
: /"((?:[^"\\]|\\.)*)"/.exec(body)?.[1])
85+
?? `line ${source.slice(0, start).split("\n").length}`;
86+
blocks.push({ title, body });
87+
}
88+
return blocks;
89+
}
90+
91+
const offenders = readdirSync(routesDir)
92+
.filter((file) => file.endsWith(".test.tsx"))
93+
.flatMap((file) =>
94+
testBlocks(readFileSync(resolve(routesDir, file), "utf8"))
95+
.filter((block) => pageSizeFixture.test(block.body) && rowByAccessibleName.test(block.body))
96+
.map((block) => `${file}${block.title}`),
97+
);
98+
99+
describe("paged-list row lookups (#557)", () => {
100+
it("resolves rows by cell text in every test that mounts a page-size fixture", () => {
101+
expect(offenders).toEqual([]);
102+
});
103+
});

web/src/test/rows.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render, within } from "@testing-library/react";
3+
import { findRowByCellText, getRowByCellText } from "./rows";
4+
5+
// #557 — these helpers exist to keep a row lookup cheap on a page-size table.
6+
// `ByRole("row", { name })` makes Testing Library compute the accessible name of
7+
// EVERY row (a full subtree walk each), and `findBy*` re-runs the whole query on
8+
// every DOM mutation, so on a 100-row paged list the cost stacks up past the 5s
9+
// default timeout on a loaded runner. Resolving the row from one cell's text is
10+
// a single indexed text match plus a `closest("tr")`.
11+
12+
function Book({ names }: { names: string[] }) {
13+
return (
14+
<table>
15+
<tbody>
16+
{names.map((name) => (
17+
<tr key={name}>
18+
<td>{name}</td>
19+
<td>555-0100</td>
20+
<td>
21+
<button>edit</button>
22+
</td>
23+
</tr>
24+
))}
25+
</tbody>
26+
</table>
27+
);
28+
}
29+
30+
describe("row lookup by cell text (#557)", () => {
31+
it("resolves the row that carries the named cell, not the whole table", async () => {
32+
render(<Book names={["Acme Eggs", "Zulu Farm"]} />);
33+
34+
const row = await findRowByCellText("Zulu Farm");
35+
36+
expect(row.tagName).toBe("TR");
37+
expect(within(row).getByText("Zulu Farm")).toBeInTheDocument();
38+
expect(within(row).queryByText("Acme Eggs")).not.toBeInTheDocument();
39+
});
40+
41+
it("resolves a row already on screen without awaiting", () => {
42+
render(<Book names={["Acme Eggs", "Zulu Farm"]} />);
43+
44+
const row = getRowByCellText("Acme Eggs");
45+
46+
expect(row.tagName).toBe("TR");
47+
expect(within(row).getByRole("button", { name: "edit" })).toBeInTheDocument();
48+
});
49+
50+
it("names the text it could not find when no row carries it", async () => {
51+
render(<Book names={["Acme Eggs"]} />);
52+
53+
await expect(findRowByCellText("Zulu Farm")).rejects.toThrow(/Zulu Farm/);
54+
expect(() => getRowByCellText("Zulu Farm")).toThrow(/Zulu Farm/);
55+
});
56+
57+
it("matches the cell's whole text, so a prefix of another customer is not confused for it", () => {
58+
render(<Book names={["Acme", "Acme Eggs"]} />);
59+
60+
expect(within(getRowByCellText("Acme")).getByText("Acme")).toBeInTheDocument();
61+
expect(within(getRowByCellText("Acme Eggs")).getByText("Acme Eggs")).toBeInTheDocument();
62+
});
63+
64+
it("reports the cell it found when that cell is not inside a row", () => {
65+
// A cell that matched but has no <tr> ancestor must not surface as a null
66+
// dereference three lines later in the calling test.
67+
render(
68+
<table>
69+
<tbody>
70+
<td>orphan cell</td>
71+
</tbody>
72+
</table>,
73+
);
74+
75+
expect(() => getRowByCellText("orphan cell")).toThrow(/orphan cell/);
76+
});
77+
});

0 commit comments

Comments
 (0)