From 0f9ed7cdbf16cbbbc643ca3cfbb76efbe0fc1dcc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 23:01:57 +0200 Subject: [PATCH] test: a list key waits for the list's registration, not only its rows The server gate's frontend job went red on 0.96.0: PlexPlaylistView's j/Delete test pressed j after the rows painted but before useListNav's passive effect registered the list, so the key did nothing on the slower runner. Every test that presses a list key after an async load now waits for [data-nav-list] on its list first (PlexPlaylistView, Playlists, Home, PlexDiscover). Checked against a 30 ms delayed registration: the old tests fail, the new ones pass. --- frontend/src/components/Home.dom.test.tsx | 4 +++- frontend/src/components/Playlists.dom.test.tsx | 12 +++++++++--- frontend/src/components/PlexDiscover.dom.test.tsx | 3 ++- .../components/PlexPlaylistView.menu.dom.test.tsx | 8 ++++++-- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index 4454df02..8b447c32 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -796,7 +796,9 @@ describe("Home · keyboard", () => { ], }); mount(); - await screen.findByText("Comfort thing"); + // The rail's keys are live once its effect registers it, which can land after the cards paint. + const first = await screen.findByText("Comfort thing"); + await waitFor(() => expect(first.closest("[data-nav-list]")).not.toBeNull()); const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"]; const seen: (string | undefined)[] = []; for (let i = 0; i < titles.length; i++) { diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index 1c338ef4..eb1988aa 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -424,6 +424,10 @@ describe("Playlists — removing a row, and undoing it", () => { .map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } }) .filter((n) => n.message?.startsWith("playlists.removedToast")) .pop()!; + // The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that + // can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake). + const keysLiveOn = (el: Element) => + waitFor(() => expect(el.closest("[data-nav-list]")).not.toBeNull()); const pressKey = (key: string) => act(() => { (document.activeElement ?? document.body).dispatchEvent( @@ -497,7 +501,7 @@ describe("Playlists — removing a row, and undoing it", () => { const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] }; serverRemoves(lists); mount(lists); - await screen.findByRole("button", { name: "card.play — Video C" }); + await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video C" })); pressKey("j"); pressKey("j"); expect(document.activeElement?.textContent).toContain("Video B"); @@ -511,7 +515,7 @@ describe("Playlists — removing a row, and undoing it", () => { vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never); h.selectedId = WATCH_LATER; mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] }); - await screen.findByRole("button", { name: "card.play — Video B" }); + await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video B" })); pressKey("j"); pressKey("Delete"); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); @@ -617,7 +621,9 @@ describe("Playlists · a narrow table", () => { describe("Playlists — the rows are a keyboard list", () => { it("walks the rows with j and plays the focused one on Enter", async () => { mount({ 1: [vid("A", 0), vid("B", 0)] }); - await screen.findByRole("button", { name: "card.play — Video B" }); + const row = await screen.findByRole("button", { name: "card.play — Video B" }); + // The keys are live once the list's effect registers it, which can land after the rows paint. + await waitFor(() => expect(row.closest("[data-nav-list]")).not.toBeNull()); const press = (key: string) => act(() => { (document.activeElement ?? document.body).dispatchEvent( diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index ed3ef4c9..70f7e906 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -289,8 +289,9 @@ describe("PlexDiscover · keyboard", () => { ); await screen.findAllByText("Random Pick"); // Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres. + // Registered by a passive effect, which can land after the cards paint. + await waitFor(() => expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(6)); const lists = [...document.querySelectorAll("[data-nav-list]")]; - expect(lists.length).toBe(6); // …and each holds its cards as items: the genre tiles too, not only the posters. expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true); const step = () => { diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx index f1d3eda7..92d6a9d0 100644 --- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -96,6 +96,10 @@ const toast = () => .map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } }) .filter((n) => n.message?.startsWith("plex.playlist.removedToast")) .pop(); +// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that +// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake). +const keysLiveOn = (el: Element) => + waitFor(() => expect(el.closest("[data-nav-list]")).not.toBeNull()); const pressKey = (key: string) => act(() => { (document.activeElement ?? document.body).dispatchEvent( @@ -254,7 +258,7 @@ describe("PlexPlaylistView · reordering without a drag (WCAG 2.5.7)", () => { describe("PlexPlaylistView · the keyboard", () => { it("walks the rows with j and takes out the walked one on Delete", async () => { mount([movie("1"), movie("2"), movie("3")]); - await screen.findByText("Movie 3"); + await keysLiveOn(await screen.findByText("Movie 3")); vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never); pressKey("j"); pressKey("j"); @@ -268,7 +272,7 @@ describe("PlexPlaylistView · the keyboard", () => { it("walks an open show's episodes after its header, and Delete on the header takes the show", async () => { mount([ep("e1", 1, 1), ep("e2", 1, 2), movie("9")]); fireEvent.click(await screen.findByText("The Show")); - await screen.findByText("Ep e2"); + await keysLiveOn(await screen.findByText("Ep e2")); pressKey("j"); expect(document.activeElement).toBe(rowOf("The Show")); pressKey("j");