mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): anchor absence claims on a rendered surface
The protected shell renders its hydration gate for every child until /api/auth/me resolves, so after a goto, a reload or an exit click nothing exists yet: no nav, no top bar, no banner, no page body. Fifteen absence assertions across twelve specs were asserted inside that window. Each was true because the surface had not rendered, and each would have stayed green if the thing it forbids came back. Every site now waits for a positive marker of the loaded state first: the document sheet's footer download action, the settled integrations card rather than the family attribute its skeleton shares, the top bar after an exit, the page's own heading, the delegate's own marker, the Coach FAB. Two skeleton-absence waits are replaced by waits on the content that follows. delegated-writes had a different cause: it asserted the bulk selection bar absent although no row was ever selected, so it passed for an owner too. The gate is one level earlier - below MANAGE no row carries a checkbox at all - so the test asserts that instead, with the bar and the row delete kept as backstops. No sleeps added, no timeouts raised, no claim weakened.
This commit is contained in:
1 parent
a889f4b1e2
commit
9cefaecf94
12 files changed
+188
-37
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -112,10 +112,26 @@ async function enterRecord(page: Page, username: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 `<main>`. 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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user