diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index 2b4453ddc..cf5d0d2ab 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -491,12 +491,16 @@ test.describe("account sharing", () => { // than on a wall of refusals: the revoke cleared their acting session in // the same transaction that stamped the row. await page.goto("/measurements"); + // Their own record, back. This goes first: the protected shell holds + // every child — nav, banner, page body — behind its hydration gate until + // `/api/auth/me` resolves, so "no banner" straight after a navigation is + // satisfied by the page still loading and would stay green if the revoke + // had left the acting session standing. The delegate's own marker only + // exists once the record really resolved to theirs. + await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible(); await expect( page.locator('[data-slot="shared-record-banner"]'), ).toHaveCount(0); - // Their own record, back — which is also the proof that the page did not - // simply fail to load. - await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible(); }); test("the ended grant stays on the record as history", async () => { diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index d55d40d49..1074b44ed 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -193,6 +193,21 @@ test.describe("delegated writes", () => { // And the row it created is not theirs to change. Absent, not disabled: // a `toBeDisabled()` assertion here would pass against the exact design // this release exists to avoid. + // + // The list has to have painted the reading first. Both layouts stamp + // `measurement-row` and only the mounted one exists, so this holds at + // either viewport and says the rows really arrived — without it every + // count below is a statement about an empty or still-loading list. + const rows = page.getByTestId("measurement-row"); + await expect(rows.first()).toBeVisible({ timeout: 30_000 }); + + // The selection column is the gate. `canManage` is false below MANAGE, so + // no row carries a checkbox — which is what makes the bulk bar + // unreachable rather than merely unrendered. The bar on its own proved + // nothing here: it renders on `count > 0`, and with nothing selected an + // owner meets no bar either, so that assertion could not fail for the + // reason this comment gives. + await expect(rows.getByRole("checkbox")).toHaveCount(0); await expect( page.locator('[data-slot="selection-action-bar"]'), ).toHaveCount(0); diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts index 204734eea..c17512663 100644 --- a/e2e/doctor-report-delegate.spec.ts +++ b/e2e/doctor-report-delegate.spec.ts @@ -112,10 +112,26 @@ async function enterRecord(page: Page, username: string): Promise { }); } +/** + * Leave the record and prove the own-record shell is back. + * + * Leaving replaces the document, and the protected shell renders its + * hydration gate — no nav, no banner, no page body — until `/api/auth/me` + * resolves. "The banner is gone" is true of that gate as well, so on its own + * it is satisfied by the document on its way out and is not a wait at all. + * The top bar only exists past the gate, so it is the anchor the absence + * hangs on. + */ async function leaveRecord(page: Page): Promise { await withDocumentReplacement(page, () => page.locator('[data-slot="shared-record-banner-exit"]').click(), ); + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({ + timeout: 30_000, + }); await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( 0, { timeout: 30_000 }, diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 2e75854b0..17a6e786c 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -373,6 +373,15 @@ test.describe("document vault — AI assist + content search", () => { const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); + // The sheet shell paints skeletons while the detail query is pending, so + // its own visibility says nothing about the body having rendered. The + // footer download action belongs to the resolved document, so waiting on + // it is what keeps the two absences below from passing against a + // skeleton that never had the line to begin with. + await expect( + sheet.locator('[data-slot="document-download"]'), + ).toBeVisible(); + await expect( sheet.locator('[data-slot="document-detail-ai-read"]'), ).toHaveCount(0); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 838d66342..6fbcfc54b 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -311,10 +311,15 @@ test.describe("document vault", () => { await expect(detail).not.toBeVisible(); await page.reload(); + // A reload replaces the document, and the protected shell holds every + // child until `/api/auth/me` resolves, so "the sheet is not visible" + // right here would be a statement about the hold, not about the handoff + // having been consumed. The list row is the first thing that exists only + // once the page really rendered, so it goes first. + await expect(openButton(page, "MRT Knie")).toBeVisible(); await expect(detail).not.toBeVisible(); expect(new URL(page.url()).searchParams.get("doc")).toBeNull(); - await expect(openButton(page, "MRT Knie")).toBeVisible(); await openButton(page, "MRT Knie").click(); await expect(detail).toBeVisible(); await detail.locator('[data-slot="document-chat-open"]').click(); diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 11672cf53..28b48c145 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -114,25 +114,23 @@ test.describe("add measurement flow", () => { ); await page.goto("/", { waitUntil: "domcontentloaded" }); - // Past the route-level skeleton first. `networkidle` says nothing about - // whether the suspended segment resolved, and every locator below lives - // inside the content that replaces it. - await expect(page.locator('[data-slot="dashboard-loading"]')).toHaveCount( - 0, - { timeout: 60_000 }, - ); - await page.waitForLoadState("networkidle"); // Open the "Add" dropdown — the dashboard's quick-entry trigger sits // at the top-right of `
`. Scope the locator there so we don't // accidentally match an "Add" button on the sidebar. const main = page.locator("main"); - await openMenu( - page, - main - .getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i }) - .first(), - ); + const addTrigger = main + .getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i }) + .first(); + // Wait for the trigger itself rather than for the route-level skeleton to + // go. The skeleton is also absent while the protected shell holds the + // whole page behind its hydration gate, so its disappearance is not a + // statement about the suspended segment having resolved; the control the + // next line clicks is. `networkidle` says nothing about either. + await expect(addTrigger).toBeVisible({ timeout: 60_000 }); + await page.waitForLoadState("networkidle"); + + await openMenu(page, addTrigger); // v1.5 phase-5: the menu items now have distinct labels — the // measurement entry says "Measurement" / "Messung" instead of "Add", diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index abe36b10a..fbc955f1d 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -51,6 +51,14 @@ test.describe("medication wizard — one-shot", () => { (url) => url.pathname === "/medications" && !url.searchParams.has("new"), ); + // The replay this guards against can only happen once the client shell is + // live and the URL effect has run, and until `/api/auth/me` resolves the + // protected shell paints nothing at all — so the absence has to hang on + // the hydrated page. The hero title is the medications page's own + // heading, and it renders only in the settled branch. + await expect(page.locator('[data-tour-id="medications-hero"]')).toBeVisible( + { timeout: 30_000 }, + ); await expect( page.locator('[data-slot="medication-wizard-dialog"]'), ).toHaveCount(0); diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index eaa6923a6..2583b460d 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -204,9 +204,10 @@ test("a fresh arrival flips the day in place", async ({ page }) => { // mean something: a note that was never rendered is also absent at the // end, and a day that was already final also reads final at the end. await page.goto("/"); - await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( - 0, - ); + // No wait on the skeleton being gone: it is also gone while the protected + // shell holds the whole page behind its hydration gate, so it resolves + // before the digest has rendered anything. The hero count below is the + // wait, and it is a wait on the content itself. const hero = page.locator('[data-slot="today-hero"]'); // Counts rather than visibility, matching the AFTER block: the two are // then the same assertion with the expected number flipped, and neither diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index 13faa6b88..455df1581 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -433,8 +433,16 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { return res.status; }); expect(left).toBe(200); - const me = await context.request.get("/api/auth/me"); - const now = (await me.json()).data.recordSession as { + // Read back through the page for the same reason the switch above goes + // that way. This was the last call in the file still using the shared + // API context, and it duly failed with `read ECONNRESET` on all three + // attempts once the pooled socket had gone stale. The browser opens its + // own connection, and a GET for the caller's own session is outside the + // app either way. + const now = (await page.evaluate(async () => { + const res = await fetch("/api/auth/me"); + return (await res.json()).data.recordSession; + })) as { epoch: number; scope: string | null; }; diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts index 53ca7303f..a71afb7a8 100644 --- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts +++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts @@ -200,10 +200,52 @@ async function openProfileRecord(page: Page) { await expect(page).toHaveURL(/\/profile$/); } +/** + * Run an act that swaps the document out, and wait until it has. + * + * Exiting a record is a full navigation. A token stamped on the window before + * the click is gone the moment the new document exists, which is the only + * signal that the old one is not still the page under test. + */ +async function withDocumentReplacement( + page: Page, + act: () => Promise, +): Promise { + await page.evaluate(() => { + (window as Window & { __hlNavToken?: true }).__hlNavToken = true; + }); + await act(); + await page.waitForFunction( + () => + (window as Window & { __hlNavToken?: true }).__hlNavToken === undefined, + undefined, + { timeout: 30_000 }, + ); +} + +/** + * Leave the record and prove the own-record shell is back. + * + * Leaving replaces the document, and the protected shell renders its + * hydration gate — no nav, no banner, no page body — until `/api/auth/me` + * resolves. "The banner is gone" is true of that gate as well, so on its own + * it is satisfied by the document on its way out and is not a wait at all. + * The top bar only exists past the gate, so it is the anchor the absence + * hangs on. + */ async function leaveRecord(page: Page) { - await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await withDocumentReplacement(page, () => + page.locator('[data-slot="shared-record-banner-exit"]').click(), + ); + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({ + timeout: 30_000, + }); await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( 0, + { timeout: 30_000 }, ); } diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 7799d023f..4b069833f 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -145,13 +145,26 @@ async function openRecord(page: Page, username: string) { return { accountId, accessLevel, recordKind }; } +/** + * Leave the record and prove the own-record shell is back. + * + * Leaving replaces the document, and the protected shell renders its + * hydration gate — no nav, no banner, no page body — until `/api/auth/me` + * resolves. "The banner is gone" is true of that gate as well, so on its own + * it is satisfied by the document on its way out and is not a wait at all. + * The top bar only exists past the gate, so it is the anchor the absence + * hangs on. + */ async function leaveRecord(page: Page) { - // Leaving is the same act pointed the other way, and it has the same gap. - // The banner being gone is satisfied by the document that is on its way out, - // so on its own it is not a wait at all. await withDocumentReplacement(page, () => page.locator('[data-slot="shared-record-banner-exit"]').click(), ); + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({ + timeout: 30_000, + }); await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( 0, { timeout: 30_000 }, @@ -432,6 +445,19 @@ test.describe.serial("scoped sharing browser journeys", () => { await expect(banner).toContainText(record.username); await page.goto("/measurements"); + // Two gates stand between the navigation and the page: the shell's own + // hold on `/api/auth/me`, and the route's own `PageAuthGate`. Both paint + // a page with no header action on it, so the absence has to hang on the + // header the button would have sat in. The hero title renders in the same + // `PageHeader` whose `actions` slot the button fills, and only in the + // settled branch, so it says the header really rendered without the + // button rather than not having rendered yet. + await expect(banner).toBeVisible({ timeout: 30_000 }); + await expect( + page.locator('[data-tour-id="measurements-hero"]'), + ).toBeVisible({ + timeout: 30_000, + }); await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0); await page.goto("/settings/account"); @@ -569,8 +595,13 @@ test.describe.serial("scoped sharing browser journeys", () => { data?: { recordId?: string }; }; expect(integrationPayload.data?.recordId).toBe(accountId); + // The loading branch of this card emits the same family attribute with + // `aria-busy="true"` on it, and a skeleton holds zero controls — so the + // read-only claim below passes against a card that never arrived. The + // settled card is the one that labels itself by its own title id, so the + // locator names that instead of the family alone. const integrations = page.locator( - '[data-record-settings-family="integrations"]', + '[data-record-settings-family="integrations"][aria-labelledby="managed-integration-status-title"]', ); await expect(integrations).toBeVisible(); await expect(integrations).toHaveAttribute("data-record-id", accountId); diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts index 29650bf10..529d127c4 100644 --- a/e2e/v138-managed-record-capabilities.spec.ts +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -68,10 +68,26 @@ async function openRecord(page: Page, username: string) { }); } +/** + * Leave the record and prove the own-record shell is back. + * + * Leaving replaces the document, and the protected shell renders its + * hydration gate — no nav, no banner, no page body — until `/api/auth/me` + * resolves. "The banner is gone" is true of that gate as well, so on its own + * it is satisfied by the document on its way out and is not a wait at all. + * The top bar only exists past the gate, so it is the anchor the absence + * hangs on. + */ async function leaveRecord(page: Page) { await withDocumentReplacement(page, () => page.locator('[data-slot="shared-record-banner-exit"]').click(), ); + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({ + timeout: 30_000, + }); await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( 0, { timeout: 30_000 }, diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 9c976824c..724a99535 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -136,19 +136,22 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - // CCH-04 — the per-metric inline launch icon is retired; no header - // launcher renders on the sub-page. - await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount( - 0, - ); - - // The FAB is the permanent launcher; with no unread nudge it + // The FAB is the permanent launcher, and it is also the proof that the + // shell got past its hydration gate: nothing — header, FAB, page body — + // exists until `/api/auth/me` resolves, so an absence asserted above + // this line is a statement about the gate. With no unread nudge the FAB // renders without the unread dot. const fab = page.locator('[data-slot="coach-fab"]'); await expect(fab).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount( 0, ); + + // The per-metric inline launch icon is retired; no header launcher + // renders on the sub-page. + await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount( + 0, + ); }); test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({ @@ -161,15 +164,18 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount( - 0, - ); - + // Same order as the mobile branch, for the same reason: the FAB is the + // marker that the shell finished hydrating, so the retired header + // launcher is claimed absent only after something has rendered. const fab = page.locator('[data-slot="coach-fab"]'); await expect(fab).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount( 0, ); + + await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount( + 0, + ); }); } diff --git a/src/__tests__/integration-truncate-deadlock-retry.test.ts b/src/__tests__/integration-truncate-deadlock-retry.test.ts new file mode 100644 index 000000000..a5b423a11 --- /dev/null +++ b/src/__tests__/integration-truncate-deadlock-retry.test.ts @@ -0,0 +1,63 @@ +/** + * The integration suite wipes the database between files with one + * `TRUNCATE`, which needs the strictest lock on every table at once. The + * server writes in thirty-nine places without awaiting the result, so one of + * those can land after the test that caused it has returned and take the + * truncate down with a deadlock. Postgres picks a victim; ours was an + * unrelated file, twice in two days, green on the rerun both times. + * + * The helper retries that one error now. These cases hold it to exactly that: + * it gives up on anything else immediately, and it does not retry forever. + */ +import { describe, expect, it, vi } from "vitest"; + +import { truncateAllTables } from "../../tests/integration/setup"; + +function deadlock(): Error & { code: string } { + const error = new Error("deadlock detected") as Error & { code: string }; + error.code = "40P01"; + return error; +} + +function fakeClient(executeRaw: ReturnType) { + const empty = vi.fn().mockResolvedValue([]); + return { + $executeRawUnsafe: executeRaw, + moodTag: { findMany: empty, createMany: vi.fn() }, + moodTagCategory: { findMany: empty, createMany: vi.fn() }, + } as unknown as Parameters[0]; +} + +describe("truncateAllTables", () => { + it("retries a deadlocked truncate and succeeds once the lock clears", async () => { + const executeRaw = vi + .fn() + .mockRejectedValueOnce(deadlock()) + .mockRejectedValueOnce(deadlock()) + .mockResolvedValue(0); + + await truncateAllTables(fakeClient(executeRaw)); + + expect(executeRaw).toHaveBeenCalledTimes(3); + }); + + it("gives up on an error that is not a deadlock, on the first attempt", async () => { + const other = new Error("permission denied") as Error & { code: string }; + other.code = "42501"; + const executeRaw = vi.fn().mockRejectedValue(other); + + await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( + "permission denied", + ); + expect(executeRaw).toHaveBeenCalledTimes(1); + }); + + it("stops retrying a deadlock that never clears", async () => { + const executeRaw = vi.fn().mockRejectedValue(deadlock()); + + await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( + "deadlock detected", + ); + expect(executeRaw).toHaveBeenCalledTimes(5); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 3430ad2e6..f4512b636 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -101,9 +101,29 @@ export async function truncateAllTables(client: PrismaClient): Promise { } const quoted = tables.map((t) => `"${t}"`).join(", "); - await client.$executeRawUnsafe( - `TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`, - ); + // A truncate takes the strictest lock there is on every table at once, so it + // loses to anything still holding a row lock. Thirty-nine places in the + // server write without awaiting the result (a last-used stamp, a delivery + // record, an audit row), and one of those can land after the test that + // triggered it has returned. Postgres then reports a deadlock and kills the + // truncate, which fails an unrelated file and passes on the next run. + // Retrying is the honest answer: a deadlock means somebody else got there + // first, and by the next attempt the stray write has finished. Only 40P01 is + // retried, so a lock held for good still fails as it should. + for (let attempt = 0; ; attempt++) { + try { + await client.$executeRawUnsafe( + `TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`, + ); + break; + } catch (error) { + const code = (error as { code?: string }).code; + const deadlock = + code === "40P01" || /deadlock detected/i.test(String(error)); + if (!deadlock || attempt >= 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } + } // Categories first — the tag rows FK into them. if (categorySnapshot.length > 0) {