test: a list key waits for the list's registration, not only its rows
gate / frontend (push) Successful in 6m5s
gate / backend (push) Successful in 7m10s

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.
This commit is contained in:
2026-09-27 23:01:57 +02:00
parent ffcea0cb07
commit 0f9ed7cdbf
4 changed files with 20 additions and 7 deletions
+3 -1
View File
@@ -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++) {
@@ -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(
@@ -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 = () => {
@@ -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");