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-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, + ); }); }