diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f72a020..208de4a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -20,12 +20,13 @@ jobs: version: 11 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm lint + - run: pnpm exec playwright install --with-deps chromium - run: pnpm test - run: pnpm build - run: pnpm check:size diff --git a/web/src/objects/object-new-page.test.tsx b/web/src/objects/object-new-page.test.tsx index b682655..a07ee9e 100644 --- a/web/src/objects/object-new-page.test.tsx +++ b/web/src/objects/object-new-page.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { delay, http, HttpResponse } from "msw"; +import { http, HttpResponse } from "msw"; import { Routes, Route } from "react-router-dom"; import { server } from "../test/server"; import { renderApp } from "../test/render"; @@ -72,11 +72,15 @@ test("partial create: fields PUT fails -> edit page shows the 'created' banner a test("in-flight submit: button disabled + shows Saving…, create fires exactly once on double-click", async () => { let postCount = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); server.use( http.post("/api/admin/objects", async () => { postCount += 1; - await delay(50); + await gate; return HttpResponse.json({ id: "new-id-3" }, { status: 201 }); }), ); @@ -91,9 +95,13 @@ test("in-flight submit: button disabled + shows Saving…, create fires exactly await userEvent.click(button); await userEvent.click(button); - await waitFor(() => expect(screen.getByText(/saving…/i)).toBeInTheDocument()); + // The mutation is held open by `gate`, so the pending state is observed + // deterministically (no reliance on a timing window). + expect(await screen.findByText(/saving…/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /saving…/i })).toBeDisabled(); + release(); + await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument()); expect(postCount).toBe(1); }); diff --git a/web/src/shell/user-menu.test.tsx b/web/src/shell/user-menu.test.tsx index 784c478..901301e 100644 --- a/web/src/shell/user-menu.test.tsx +++ b/web/src/shell/user-menu.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { delay, http, HttpResponse } from "msw"; +import { http, HttpResponse } from "msw"; import { server } from "../test/server"; import { renderApp } from "../test/render"; import { UserMenu } from "./user-menu"; @@ -35,9 +35,13 @@ test("opens the menu showing email + role and signs out", async () => { }); test("shows a pending state on Sign out while logging out", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); server.use( http.post("/api/admin/logout", async () => { - await delay(50); + await gate; return new HttpResponse(null, { status: 204 }); }), ); @@ -50,5 +54,10 @@ test("shows a pending state on Sign out while logging out", async () => { const menu = within(document.body); await userEvent.click(await menu.findByText("Sign out")); + // The logout is held open by `gate`, so the pending state is observed + // deterministically (no reliance on a timing window). expect(await menu.findByText(/signing out/i)).toBeInTheDocument(); + + release(); + await waitFor(() => expect(menu.queryByText(/signing out/i)).toBeNull()); }); diff --git a/web/vite.config.ts b/web/vite.config.ts index b777c42..1b66e94 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -28,6 +28,9 @@ export default defineConfig({ extends: true, test: { environment: "jsdom", + // The CI runner is heavily resource-constrained; lazy-loaded chunks + // (e.g. the object-detail drawer) can exceed the 5s default. + testTimeout: 20000, globals: true, setupFiles: ["./src/test/setup.ts"], environmentOptions: { @@ -46,6 +49,7 @@ export default defineConfig({ })], test: { name: 'storybook', + testTimeout: 20000, browser: { enabled: true, headless: true,