test: tests that assumed an idle machine wait for what they assert, or carry a budget for their work

Under a busy machine (the gate's lanes side by side, 12 busy cores) these went red at random: a press landed while the button was still busy, an answer was settled before it was asked for, a page grew past one wait's budget, a lazy chunk was transformed inside a 1 s wait, focus was taken a frame after the press. They now wait for the condition they depend on, preload the chunks they open, and the tests that do heavy work (whole-repo scans, the type-aware linter, module resets) carry an explicit budget naming that work. The whole suite four times under 12 busy-loop processes: red 4/4 before, 0/4 after.
This commit is contained in:
2026-10-17 01:08:15 +02:00
parent 926e5e9899
commit 5ba5679239
18 changed files with 181 additions and 96 deletions
@@ -75,10 +75,12 @@ describe("Feed — the count while a new search's count loads", () => {
it("dims the previous count and marks it busy until the new one lands", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
let settle: (v: unknown) => void = () => {};
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: new Promise((r) => (settle = r))) as never);
const summary = vi
.spyOn(api, "feedSummary")
.mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: new Promise((r) => (settle = r))) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const pill = () => screen.getByTestId("item-count");
@@ -91,6 +93,9 @@ describe("Feed — the count while a new search's count loads", () => {
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
// The pill turns busy in the render that changes the search, and the count is asked for in an
// effect after it: answer only once it has been asked, or the answer goes to no one.
await waitFor(() => expect(summary).toHaveBeenCalledWith(expect.objectContaining({ q: "a" })));
settle({ count: 178991, facets: {} });
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
expect(pill().getAttribute("aria-busy")).toBeNull();
@@ -7,7 +7,7 @@
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys.
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
@@ -118,6 +118,17 @@ import Files from "./Files";
import { FILES_LIST_CAP, type QueueJob } from "../lib/api";
import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue";
// The page loads its dialogs lazily, and the first test to open one would pay for transforming the
// chunk inside its own 1 s wait for the dialog — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await Promise.all([
import("./FilePreview"),
import("./ShareDialog"),
import("./UploadDialog"),
import("./VideoEditor"),
]);
}, 60_000);
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
id,
@@ -452,7 +463,9 @@ describe("Files · my file's row", () => {
});
});
describe("Files · a library past the list's cap", () => {
// Its cases draw a full page of FILES_LIST_CAP rows, some of them twice (a sort, then its answer):
// up to ~0.8 s idle, past 5 s when the full suite's workers share a busy machine.
describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => {
mocks.folders = [YT];
mocks.files = [file(1, { display_name: "Recept" }), file(2)];
@@ -35,6 +35,12 @@ import FilesRail from "./FilesRail";
import { api, type FileFolder } from "../lib/api";
import { defaultLayout } from "../lib/panelLayout";
// The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside
// its own wait for the dialog — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await import("./ShareDialog");
}, 60_000);
beforeAll(() => {
class Stub {
observe() {}
@@ -251,10 +257,7 @@ describe("FilesRail · a folder shared by link", () => {
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.folder.shareLink")!,
);
// The dialog is its own chunk, fetched on first use: slower than a re-render under a full run.
expect(
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
).toBeTruthy();
expect(await screen.findByText("downloads.share.folderLinkHint")).toBeTruthy();
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
});
});
@@ -394,6 +394,11 @@ describe("a Plex title's menu — Add to collection", () => {
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
// on <body> with the menu open — it lands on the first row that came back.
const retry = (await screen.findByText("common.retry")).closest("button")!;
// The menu takes the focus a frame after it opens. Wait for that: a frame that came only after the
// press would put the focus back on the panel over the hand-over below — a test faster than a
// frame, not a reader, who cannot press a Retry they have not seen painted yet.
const panel = retry.closest("[data-floating-ui-portal]")!;
await vi.waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
retry.focus();
fireEvent.click(retry);
const beta = await checkRow("Beta");
@@ -28,6 +28,11 @@ import { pushLayer, removeLayer } from "../lib/layerStack";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
// The info overlay is a lazy chunk, and the first test to open it would pay for transforming it
// inside its own wait for the overlay — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await import("./PlexInfo");
}, 60_000);
beforeAll(() => {
class Stub {
observe() {}
@@ -504,8 +509,7 @@ describe("a collection sibling in the info overlay", () => {
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
// The overlay is a lazy chunk: its first import takes a while under the test runner.
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
const restart = await screen.findByRole("button", { name: "card.restart" });
fireEvent.click(restart);
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
@@ -526,9 +530,7 @@ describe("a collection sibling in the info overlay", () => {
const asked = session.mock.calls.length;
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
fireEvent.click(
await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }),
);
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
await waitFor(() => expect(video()._t).toBe(0));
expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload
expect(video().paused).toBe(false);
@@ -578,7 +580,7 @@ describe("a Restart of the paused film whose session starts past 0", () => {
act(() => void video().pause());
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
const restart = await screen.findByRole("button", { name: "card.restart" });
const plays = play.mock.calls.length;
fireEvent.click(restart);
await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1));
@@ -25,6 +25,10 @@ beforeAll(async () => {
await import("./Welcome");
}, 60_000);
// Warm or not, each case still loads and evaluates the whole Welcome tree afresh after its module
// reset: ~0.1 s idle, past 5 s when the full suite's workers share a busy machine (seen at 5.01 s).
const CASE_BUDGET = 30_000;
afterEach(() => {
cleanup();
vi.resetModules();
@@ -85,7 +89,7 @@ function refusal(
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
}
describe("a refused sign-in becomes a named state", () => {
describe("a refused sign-in becomes a named state", { timeout: CASE_BUDGET }, () => {
it("names the address the account is on, not the one that was typed", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
@@ -164,7 +168,7 @@ describe("a refused sign-in becomes a named state", () => {
// password" form purely because a `#reset=` fragment was present, and only told the user after they
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
// whoever got two mails clicks the older one.
describe("an emailed reset link that is already finished", () => {
describe("an emailed reset link that is already finished", { timeout: CASE_BUDGET }, () => {
it("refuses to draw a form that cannot succeed", async () => {
await mountCard("/#reset=dead-token", {
checkPasswordReset: () => Promise.resolve({ ok: false }),
@@ -250,7 +254,7 @@ describe("an emailed reset link that is already finished", () => {
});
});
describe("the access-request notice from a denied Google sign-in", () => {
describe("the access-request notice from a denied Google sign-in", { timeout: CASE_BUDGET }, () => {
it("names the address the request was recorded for", async () => {
await mountCard("/#access=requested&email=nptemu%40gmail.com");
@@ -6,7 +6,7 @@
//
// i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -42,6 +42,9 @@ async function failTwice(fail: () => void, message: string) {
fail();
const first = await screen.findByRole("alert");
expect(first.textContent).toBe(message);
// The wizard's Next is busy until its failed step returns (a tick after the message shows), and a
// busy button ignores a press: press again only once nothing on the form says it is working.
await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeNull());
fail();
const second = screen.getByRole("alert");
expect(second.textContent).toBe(message);
@@ -47,6 +47,15 @@ afterEach(() => {
const bodyRows = () => host!.querySelectorAll("tbody tr").length;
// Waits for the page to grow to `n` rows, one background step at a time: each step must come, but on
// a busy machine all of them together can outlast one wait's budget (seen at 190 of 200).
async function grownTo(n: number) {
for (let had = bodyRows(); had < n; had = bodyRows()) {
await waitFor(() => expect(bodyRows()).toBeGreaterThan(had));
}
expect(bodyRows()).toBe(n);
}
// Mounts synchronously, so what the DOM holds right after is the first render alone.
function mount(rows: Row[], columns: Column<Row>[], narrowedBy = "") {
host = document.createElement("div");
@@ -68,7 +77,7 @@ describe("DataTable — a long page mounts in steps", () => {
it("paints its first rows at once and the rest after", async () => {
mount(many(200), plain);
expect(bodyRows()).toBe(FIRST_ROWS);
await waitFor(() => expect(bodyRows()).toBe(200));
await grownTo(200);
});
it("does not re-render the rows it already holds as it grows", async () => {
@@ -84,21 +93,21 @@ describe("DataTable — a long page mounts in steps", () => {
},
];
mount(many(200), counted);
await waitFor(() => expect(bodyRows()).toBe(200));
await grownTo(200);
// The first row was drawn by the first render, not again by each step after it.
expect(renders.get("r0")).toBe(1);
});
it("starts over from the first rows when the caller narrows them, not on new data", async () => {
const draw = mount(many(200), plain, "");
await waitFor(() => expect(bodyRows()).toBe(200));
await grownTo(200);
// New data for the same view (a refetch): the page keeps what it shows.
draw(many(200), "");
expect(bodyRows()).toBe(200);
// A search the caller applied: the urgent render is small again.
draw(many(150, "s"), "s");
expect(bodyRows()).toBe(FIRST_ROWS);
await waitFor(() => expect(bodyRows()).toBe(150));
await grownTo(150);
});
// Channels turns the page to the row a priority pick moved and focuses it in the commit
@@ -33,13 +33,18 @@ beforeAll(async () => {
await import("./api/core");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, in a tab not yet pinned", () => {
describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once the tab is pinned", async () => {
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
@@ -65,7 +70,7 @@ describe("stores read at import, in a tab not yet pinned", () => {
});
});
describe("a pinned tab pinned to another account", () => {
describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => {
it("leaves the previous account's notices with that account", async () => {
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
sessionStorage.setItem("siftlode.activeAccount", "4");
+3 -1
View File
@@ -71,7 +71,9 @@ beforeAll(async () => {
slices[name] = slice;
}
api = ((await import("../api")) as { api: Slice }).api;
});
// The imports above transform the whole api graph and i18n with every locale: under a ~0.6 s idle,
// but past the 10 s hook budget when the full suite's workers share a busy machine.
}, 60_000);
describe("api façade is a collision-free spread of domain slices", () => {
it("defines no method name in more than one slice (object spread would silently drop one)", () => {
@@ -13,19 +13,25 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr
}
describe("a hand-rolled button", () => {
it("is refused in a feature component, the lab and lib, raw or focus-safe", async () => {
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
expect(await hits(raw)).toBe(1);
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
expect(
await hits(
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
),
).toBe(1);
});
// Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat
// ~0): this case lints four, ~1.4 s idle, past 5 s on a busy machine.
it(
"is refused in a feature component, the lab and lib, raw or focus-safe",
{ timeout: 30_000 },
async () => {
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
expect(await hits(raw)).toBe(1);
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
expect(
await hits(
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
),
).toBe(1);
},
);
it("is allowed only in a control primitive, and the primitives pass everywhere", async () => {
for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"])
+6 -1
View File
@@ -33,6 +33,11 @@ beforeAll(async () => {
await import("./api");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
@@ -69,7 +74,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
const codes = (batch: unknown[] | undefined) =>
(batch ?? []).map((e) => (e as { event: string }).event);
describe("diagnostics level", () => {
describe("diagnostics level", { timeout: CASE_BUDGET }, () => {
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
const d = await fresh();
d.applyDiagnostics(
@@ -54,6 +54,11 @@ beforeAll(async () => {
}, 60_000);
let current: typeof import("./diagnostics") | null = null;
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
@@ -73,7 +78,7 @@ async function fresh() {
const flat = () => sent.flat();
describe("a debug session applied to the tab", () => {
describe("a debug session applied to the tab", { timeout: CASE_BUDGET }, () => {
it("raises the base level to its own, and tags and streams what it keeps", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
@@ -14,16 +14,22 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr
}
describe("a hand-rolled field", () => {
it("is refused anywhere outside the field primitives, text box or native control", async () => {
const text = 'export const A = () => <input className="border px-2" />;\n';
expect(await hits(text)).toBe(1);
expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1);
// …and in a ui/ composite: the pager's page box is an Input too.
expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1);
expect(await hits("export const B = () => <textarea />;\n")).toBe(1);
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
expect(await hits('export const D = () => <input type="range" />;\n')).toBe(1);
});
// Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat
// ~0): this case lints three, ~1 s idle, past 5 s on a busy machine.
it(
"is refused anywhere outside the field primitives, text box or native control",
{ timeout: 30_000 },
async () => {
const text = 'export const A = () => <input className="border px-2" />;\n';
expect(await hits(text)).toBe(1);
expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1);
// …and in a ui/ composite: the pager's page box is an Input too.
expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1);
expect(await hits("export const B = () => <textarea />;\n")).toBe(1);
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
expect(await hits('export const D = () => <input type="range" />;\n')).toBe(1);
},
);
it("passes in a field primitive, for a file picker, and for the primitives themselves", async () => {
for (const file of ["Input.tsx", "NativeControls.tsx"])
+2 -1
View File
@@ -103,7 +103,8 @@ describe("plan codes", () => {
expect(pattern("PLAN_CODE")).toBe(PLAN_CODES.map(js).join("|"));
});
it("are not cited in the code", () => {
// Reads every tracked and new file of the repo: ~0.6 s idle, past 5 s on a busy machine's disk and CPU.
it("are not cited in the code", { timeout: 30_000 }, () => {
const files = tracked();
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
const hits = files.flatMap((f) =>
@@ -38,7 +38,8 @@ function files(dir: string): string[] {
}
describe("removed features stay removed", () => {
it("leaves no trace of the demo account", () => {
// Reads every source file of the repo: ~0.4 s idle, past 5 s on a busy machine's disk and CPU.
it("leaves no trace of the demo account", { timeout: 30_000 }, () => {
const hits = ROOTS.flatMap(files)
.filter((f) => !ALLOWED.has(f))
.flatMap((f) =>
+50 -41
View File
@@ -18,6 +18,11 @@ beforeAll(async () => {
await import("./useVerifyEntry");
}, 60_000);
// Warm or not, each case still loads and evaluates the hook's graph afresh after its module reset:
// ~60 ms idle, past 5 s when the full suite's workers share a busy machine — and a case cut off
// mid-mount leaves its render behind for the next one to trip over.
const CASE_BUDGET = 30_000;
afterEach(() => {
cleanup();
vi.resetModules();
@@ -52,7 +57,7 @@ async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
return () => screen.getByTestId("outcome").textContent;
}
describe("confirming an emailed verification link", () => {
describe("confirming an emailed verification link", { timeout: CASE_BUDGET }, () => {
it("posts the fragment token and reports the confirmed address", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
@@ -253,52 +258,56 @@ describe("confirming an emailed verification link", () => {
// already runs the app to a URL differing only in the fragment is a same-document navigation, so
// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the
// address bar — the same silence the sprint set out to remove, wearing a different hat.
describe("a verification link that arrives as a fragment-only navigation", () => {
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
const outcome = await mountAt("/", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
describe(
"a verification link that arrives as a fragment-only navigation",
{ timeout: CASE_BUDGET },
() => {
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
const outcome = await mountAt("/", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
await act(async () => {
window.history.replaceState(null, "", "/#verify=pasted-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
expect(outcome()).toBe("ok:pasted@example.com");
expect(window.location.hash).toBe("");
});
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
const verifyEmail = vi
.fn()
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
// token is spent, and this case is about the SECOND outcome replacing the first.
.mockImplementationOnce(async () => {
const { HttpError } = await import("./api/core");
throw new HttpError(400, "verify", { code: "verify" });
await act(async () => {
window.history.replaceState(null, "", "/#verify=pasted-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
const outcome = await mountAt("/#verify=first-token", verifyEmail);
expect(outcome()).toBe("ok:first@example.com");
await act(async () => {
window.history.replaceState(null, "", "/#verify=spent-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
expect(outcome()).toBe("ok:pasted@example.com");
expect(window.location.hash).toBe("");
});
expect(outcome()).toBe("invalid:-");
});
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
const verifyEmail = vi
.fn()
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
// token is spent, and this case is about the SECOND outcome replacing the first.
.mockImplementationOnce(async () => {
const { HttpError } = await import("./api/core");
throw new HttpError(400, "verify", { code: "verify" });
});
const outcome = await mountAt("/#verify=first-token", verifyEmail);
expect(outcome()).toBe("ok:first@example.com");
it("posts a given token once even if the same fragment arrives again", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
await mountAt("/#verify=same-token", verifyEmail);
await act(async () => {
window.history.replaceState(null, "", "/#verify=spent-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
await act(async () => {
window.history.replaceState(null, "", "/#verify=same-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(outcome()).toBe("invalid:-");
});
expect(verifyEmail).toHaveBeenCalledTimes(1);
});
});
it("posts a given token once even if the same fragment arrives again", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
await mountAt("/#verify=same-token", verifyEmail);
await act(async () => {
window.history.replaceState(null, "", "/#verify=same-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(verifyEmail).toHaveBeenCalledTimes(1);
});
},
);
+2 -1
View File
@@ -103,7 +103,8 @@ describe("the /watch entry", () => {
expect(leaks).toEqual([]);
});
it("catches a leak one import deep (the walk is not vacuous)", () => {
// Parses every module the whole app reaches: ~1.3 s idle, past 5 s on a busy machine.
it("catches a leak one import deep (the walk is not vacuous)", { timeout: 30_000 }, () => {
// main.tsx is the app: walking it must find the app's i18n, and a page it only loads lazily.
const app = reach(join(SRC, "main.tsx"));
expect(app.has("src/i18n/index.ts")).toBe(true);