mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
638 lines
26 KiB
TypeScript
638 lines
26 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
|
|
import { expect, test } from "./setup/test";
|
|
import { FENCE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
|
|
|
/**
|
|
* The record-session fence, in a real browser.
|
|
*
|
|
* `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer:
|
|
* a peer tab holds while the initiator's switch is in flight, because the
|
|
* initiator told it to. This file proves the cases that layer cannot reach —
|
|
* the ones where nobody tells anybody anything:
|
|
*
|
|
* * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely
|
|
* to "something changed";
|
|
* * FENCE-AC-02 the initiator's response never arrives, because the tab is
|
|
* torn off mid-flight;
|
|
* * FENCE-AC-03 the initiator is CLOSED after the server committed, so no
|
|
* commit broadcast is ever published;
|
|
* * FENCE-AC-06 a record response delayed across the switch is discarded by
|
|
* the client rather than painted;
|
|
* * FENCE-AC-07 the disk layers — CacheStorage and the service worker's
|
|
* `healthlog-data-*` — cannot serve one record's bytes inside another.
|
|
*
|
|
* ## Why every absence assertion here is paired
|
|
*
|
|
* "The peer never showed the owner's data" passes on a page that is
|
|
* permanently wedged, which is a worse outcome than the bug. So each of those
|
|
* legs additionally asserts the peer REACHES a usable state on the target
|
|
* scope once `/me` resolves. An absence-only assertion in this file would be a
|
|
* check that cannot fail.
|
|
*
|
|
* Uses the existing synthetic `e2e/setup` fixtures; introduces no new account
|
|
* anchors.
|
|
*/
|
|
|
|
const RECORD_READS = [
|
|
"/api/dashboard/snapshot",
|
|
"/api/insights/targets",
|
|
"/api/medications",
|
|
"/api/medications/compliance",
|
|
"/api/labs",
|
|
] as const;
|
|
|
|
const EPOCH_HEADER = "x-healthlog-record-epoch";
|
|
const SCOPE_HEADER = "x-healthlog-record-scope";
|
|
|
|
/**
|
|
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
|
*
|
|
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
|
* nav drops the other destinations and the record reads this file drives are
|
|
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
|
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
|
* hides the thing under test.
|
|
*/
|
|
const TARGET_USERNAME = "e2e-level-read";
|
|
|
|
async function openSwitcher(page: Page) {
|
|
await page.getByRole("button", { name: "User menu" }).first().click();
|
|
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
|
}
|
|
|
|
function switcherEntry(page: Page, username = TARGET_USERNAME) {
|
|
return page.locator(
|
|
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
|
);
|
|
}
|
|
|
|
function deferred() {
|
|
let release!: () => void;
|
|
const promise = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
return { promise, release };
|
|
}
|
|
|
|
/**
|
|
* Leave any record this session is already inside.
|
|
*
|
|
* These specs each begin by switching INTO a record, so they need to start
|
|
* outside one — and the session is shared with the other sharing journeys, so
|
|
* "outside one" is not something a fresh page can assume. Without this the
|
|
* file passes in isolation and fails whenever it runs after a spec that left
|
|
* the session switched, which is the least useful kind of failure.
|
|
*/
|
|
async function ensureOwnRecord(page: Page) {
|
|
const banner = page.locator('[data-slot="shared-record-banner"]');
|
|
if ((await banner.count()) === 0) return;
|
|
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
|
await expect(banner).toHaveCount(0, { timeout: 30_000 });
|
|
// And WAIT for the reload to finish.
|
|
//
|
|
// Leaving a record is a full document navigation, and the banner vanishing
|
|
// only means that navigation started. Returning there hands the next step a
|
|
// page that has not re-read `/api/auth/me` yet — so its adopted epoch is
|
|
// still `bootstrap`, its switch sends `expectedEpoch: 0`, the compare-and-set
|
|
// loses against the epoch the exit itself just moved, and the switch quietly
|
|
// does not happen. The symptom is a banner that never appears, thirty
|
|
// seconds later, with nothing wrong in the product.
|
|
await expectShellReady(page);
|
|
}
|
|
|
|
/**
|
|
* Enter the target record, retrying the click if the switch did not land.
|
|
*
|
|
* Not a workaround for a flaky product — a fixture acknowledging a contract the
|
|
* product chose deliberately. `POST /api/account/switch` is a compare-and-set
|
|
* on the session's record epoch, and the client reconciles and retries exactly
|
|
* ONCE; a second lost CAS surfaces as a failed toast rather than being retried
|
|
* forever, which is the right call for a person at a keyboard.
|
|
*
|
|
* These tests are not a person at a keyboard. Some drive two tabs of ONE
|
|
* session at machine speed while the peer is reloading and re-adopting, which
|
|
* is precisely the "the browser is racing itself" case that contract names —
|
|
* so the switcher click legitimately loses sometimes, and the product is
|
|
* behaving as designed when it does. The fixture re-presses the button, which
|
|
* is what the person would do.
|
|
*
|
|
* Bounded and asserted: if three attempts cannot enter the record, that is a
|
|
* real failure and it fails.
|
|
*/
|
|
async function enterRecord(page: Page, username = TARGET_USERNAME) {
|
|
const banner = page.locator('[data-slot="shared-record-banner"]');
|
|
let accountId: string | null = null;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await openSwitcher(page);
|
|
const entry = switcherEntry(page, username);
|
|
await expect(entry).toBeVisible();
|
|
accountId ??= await entry.getAttribute("data-account-id");
|
|
await entry.click();
|
|
try {
|
|
await expect(banner).toBeVisible({ timeout: 15_000 });
|
|
return accountId;
|
|
} catch {
|
|
// The switcher menu may still be open over the page; start clean.
|
|
await page.goto("/");
|
|
await expectShellReady(page);
|
|
}
|
|
}
|
|
await expect(banner).toBeVisible({ timeout: 15_000 });
|
|
return accountId;
|
|
}
|
|
|
|
/** Wait for the shell to be usable again — the paired positive control. */
|
|
async function expectShellReady(page: Page) {
|
|
await expect(
|
|
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
|
// A real budget, not the 10 s default. The gate IS the cross-tab hold: a
|
|
// peer sits behind it until its own `/api/auth/me` resolves, and that read
|
|
// queues behind whatever the other worker's browser is doing. Ten seconds
|
|
// is a statement about machine load; thirty is about the hold actually
|
|
// never releasing, which is the failure worth reporting.
|
|
).toHaveCount(0, { timeout: 30_000 });
|
|
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The furthest-along assertion in a collected set.
|
|
*
|
|
* The epoch is monotonic, so the highest one is the freshest context this
|
|
* browser has reached — independent of the order the requests happened to be
|
|
* observed in.
|
|
*/
|
|
function latestAssertion(
|
|
collected: { epoch: string; scope: string }[],
|
|
): { epoch: string; scope: string } | null {
|
|
let best: { epoch: string; scope: string } | null = null;
|
|
for (const assertion of collected) {
|
|
if (!/^\d+$/.test(assertion.epoch)) continue;
|
|
if (best === null || Number(assertion.epoch) > Number(best.epoch)) {
|
|
best = assertion;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** The record context a page asserted on its most recent record read. */
|
|
function collectAssertions(
|
|
page: Page,
|
|
into: { epoch: string; scope: string }[],
|
|
) {
|
|
page.on("request", (request) => {
|
|
const path = new URL(request.url()).pathname;
|
|
if (!(RECORD_READS as readonly string[]).includes(path)) return;
|
|
const headers = request.headers();
|
|
const epoch = headers[EPOCH_HEADER];
|
|
const scope = headers[SCOPE_HEADER];
|
|
if (epoch === undefined || scope === undefined) return;
|
|
into.push({ epoch, scope });
|
|
});
|
|
}
|
|
|
|
test.describe.serial("FENCE record-session fence in the browser", () => {
|
|
test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({
|
|
browser,
|
|
}) => {
|
|
// Two full switches, two peer reloads each, and a poll that reloads until
|
|
// the server write is visible to the second tab. That does not fit the
|
|
// 30 s default, and a test that times out reports a failure that says
|
|
// nothing about the fence.
|
|
test.setTimeout(120_000);
|
|
const context = await browser.newContext({
|
|
storageState: FENCE_STORAGE_STATE_PATH,
|
|
});
|
|
const initiator = await context.newPage();
|
|
const peer = await context.newPage();
|
|
const peerAssertions: { epoch: string; scope: string }[] = [];
|
|
collectAssertions(peer, peerAssertions);
|
|
|
|
try {
|
|
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
|
await expectShellReady(peer);
|
|
await expectShellReady(initiator);
|
|
await ensureOwnRecord(initiator);
|
|
await peer.reload();
|
|
await expectShellReady(peer);
|
|
|
|
const accountId = await enterRecord(initiator);
|
|
expect(accountId).not.toBeNull();
|
|
|
|
// Cleared HERE, not before the click: between the click and the reload
|
|
// the peer is still issuing reads under the context it had, and counting
|
|
// those made the assertion below compare a pre-switch `self` against the
|
|
// post-switch account. Only what the peer asserts once it has reconciled
|
|
// is evidence about reconciliation.
|
|
peerAssertions.length = 0;
|
|
|
|
// The peer reaches a usable shell again — the positive control, without
|
|
// which every assertion below would pass on a wedged page.
|
|
await peer.reload();
|
|
await expectShellReady(peer);
|
|
await expect(
|
|
peer.locator('[data-slot="shared-record-banner"]'),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
|
|
// And every record read it now issues names the record it is actually
|
|
// in, not "something". A peer that reconciled to a guess would show up
|
|
// here as a scope naming the wrong account or a stale epoch.
|
|
// The HIGHEST-epoch assertion, not every collected one.
|
|
//
|
|
// The epoch is monotonic, and requests do not arrive in the order they
|
|
// were issued — a read from the page before the reload can be observed
|
|
// after one from the page after it. Requiring EVERY collected assertion
|
|
// to name the new record is therefore a claim about network ordering
|
|
// rather than about reconciliation, and it fails intermittently for a
|
|
// reason that has nothing to do with the fence. What reconciliation
|
|
// means is that the peer CONVERGES on the new context, which is exactly
|
|
// the highest epoch it ever asserts.
|
|
const inRecord = latestAssertion(peerAssertions);
|
|
expect(inRecord).not.toBeNull();
|
|
expect(inRecord!.scope).toBe(accountId);
|
|
const inRecordEpoch = Number(inRecord!.epoch);
|
|
expect(inRecordEpoch).toBeGreaterThan(0);
|
|
|
|
// ── and the same on the way out ───────────────────────────────────
|
|
await initiator
|
|
.locator('[data-slot="shared-record-banner-exit"]')
|
|
.click();
|
|
await expect(
|
|
initiator.locator('[data-slot="shared-record-banner"]'),
|
|
).toHaveCount(0, { timeout: 30_000 });
|
|
|
|
peerAssertions.length = 0;
|
|
// Polled with reloads rather than reloaded once. The initiator's banner
|
|
// disappearing means its own document navigated, not that the server
|
|
// write has landed and become visible to another tab — asserting on one
|
|
// reload made this leg a race against the switch-out commit.
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
await peer.reload();
|
|
await expectShellReady(peer);
|
|
return peer.locator('[data-slot="shared-record-banner"]').count();
|
|
},
|
|
{ timeout: 40_000 },
|
|
)
|
|
.toBe(0);
|
|
|
|
const backHome = latestAssertion(peerAssertions);
|
|
expect(backHome).not.toBeNull();
|
|
expect(backHome!.scope).toBe("self");
|
|
// Leaving moved the epoch too: a tab believing it is still inside the
|
|
// record is the same defect as the reverse.
|
|
expect(Number(backHome!.epoch)).toBeGreaterThan(inRecordEpoch);
|
|
} finally {
|
|
// Hand the shared session back to its own record.
|
|
//
|
|
// These fixtures' storage state is shared with the other sharing
|
|
// journeys, and every test here deliberately moves the session's record
|
|
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
|
// that has nothing to do with it — which is how a green file and a red
|
|
// suite happen at the same time.
|
|
await context.request
|
|
.post("/api/account/switch", { data: { accountId: null } })
|
|
.catch(() => {});
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({
|
|
browser,
|
|
}) => {
|
|
const context = await browser.newContext({
|
|
storageState: FENCE_STORAGE_STATE_PATH,
|
|
});
|
|
const initiator = await context.newPage();
|
|
const peer = await context.newPage();
|
|
const peerAssertions: { epoch: string; scope: string }[] = [];
|
|
collectAssertions(peer, peerAssertions);
|
|
|
|
try {
|
|
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
|
await expectShellReady(peer);
|
|
await expectShellReady(initiator);
|
|
await ensureOwnRecord(initiator);
|
|
|
|
// Drive the switch server-side and abort the response on the way back:
|
|
// the write lands, the initiator never learns it did, and no commit
|
|
// broadcast is ever published.
|
|
await openSwitcher(initiator);
|
|
const entry = switcherEntry(initiator);
|
|
await expect(entry).toBeVisible();
|
|
const accountId = await entry.getAttribute("data-account-id");
|
|
expect(accountId).not.toBeNull();
|
|
|
|
let switchCommitted = false;
|
|
await initiator.route("**/api/account/switch", async (route) => {
|
|
await route.fetch();
|
|
switchCommitted = true;
|
|
await route.abort();
|
|
});
|
|
await entry.click({ noWaitAfter: true });
|
|
// Poll for the server round-trip having landed (the observable half of
|
|
// the setup), THEN hold a short fixed window — this one is a true
|
|
// absence-assertion grace (C3): "the peer never painted guessed state"
|
|
// has no completion signal to poll on, only time in which the wrong
|
|
// paint could have happened.
|
|
await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true);
|
|
await initiator.waitForTimeout(500);
|
|
|
|
// The peer must never paint owner-ready state it guessed at …
|
|
const guessed = peerAssertions.filter(
|
|
(a) => a.scope === accountId && a.epoch === "bootstrap",
|
|
);
|
|
expect(guessed).toEqual([]);
|
|
|
|
// … and must still REACH ready on the target scope once `/me` answers.
|
|
// Without this the leg above would pass on a permanently wedged page.
|
|
await peer.reload();
|
|
await expectShellReady(peer);
|
|
await expect(
|
|
peer.locator('[data-slot="shared-record-banner"]'),
|
|
).toBeVisible();
|
|
expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true);
|
|
} finally {
|
|
// Hand the shared session back to its own record.
|
|
//
|
|
// These fixtures' storage state is shared with the other sharing
|
|
// journeys, and every test here deliberately moves the session's record
|
|
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
|
// that has nothing to do with it — which is how a green file and a red
|
|
// suite happen at the same time.
|
|
await context.request
|
|
.post("/api/account/switch", { data: { accountId: null } })
|
|
.catch(() => {});
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({
|
|
browser,
|
|
}) => {
|
|
const context = await browser.newContext({
|
|
storageState: FENCE_STORAGE_STATE_PATH,
|
|
});
|
|
const initiator = await context.newPage();
|
|
const peer = await context.newPage();
|
|
|
|
try {
|
|
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
|
await expectShellReady(peer);
|
|
await expectShellReady(initiator);
|
|
await ensureOwnRecord(initiator);
|
|
await peer.reload();
|
|
await expectShellReady(peer);
|
|
|
|
await openSwitcher(initiator);
|
|
const entry = switcherEntry(initiator);
|
|
await expect(entry).toBeVisible();
|
|
|
|
// Hold the response, close the tab while it is in flight. The server has
|
|
// committed; nothing will ever broadcast the commit.
|
|
const held = deferred();
|
|
// The commit is observable: flag once the server round-trip inside the
|
|
// route handler finishes, and poll for it instead of a fixed sleep
|
|
// The tab must close AFTER the write landed, and this waits
|
|
// exactly that long.
|
|
let switchCommitted = false;
|
|
await initiator.route("**/api/account/switch", async (route) => {
|
|
await route.fetch();
|
|
switchCommitted = true;
|
|
await held.promise;
|
|
await route.abort();
|
|
});
|
|
await entry.click({ noWaitAfter: true });
|
|
await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true);
|
|
held.release();
|
|
await initiator.close();
|
|
|
|
// The surviving tab is not permanently gated: `/me` is the bootstrap and
|
|
// it answers regardless of who began the transition.
|
|
await peer.reload();
|
|
await expectShellReady(peer);
|
|
} finally {
|
|
// Hand the shared session back to its own record.
|
|
//
|
|
// These fixtures' storage state is shared with the other sharing
|
|
// journeys, and every test here deliberately moves the session's record
|
|
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
|
// that has nothing to do with it — which is how a green file and a red
|
|
// suite happen at the same time.
|
|
await context.request
|
|
.post("/api/account/switch", { data: { accountId: null } })
|
|
.catch(() => {});
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({
|
|
browser,
|
|
}) => {
|
|
const context = await browser.newContext({
|
|
storageState: FENCE_STORAGE_STATE_PATH,
|
|
});
|
|
const peer = await context.newPage();
|
|
// Collected from the very start: the refusal can land on a poll the app
|
|
// issues by itself, before this test navigates anywhere.
|
|
const refusals: string[] = [];
|
|
peer.on("response", (response) => {
|
|
const path = new URL(response.url()).pathname;
|
|
if (response.status() !== 409) return;
|
|
// ANY refused API read, not one of a named five. Which cells a page
|
|
// mounts is a rendering decision that moves with the product, and a
|
|
// hard-coded list turns "the fence refused the stale tab" into "the
|
|
// fence refused one of the paths I happened to name" — which is how
|
|
// this case first passed while observing nothing.
|
|
if (!path.startsWith("/api/")) return;
|
|
refusals.push(path);
|
|
});
|
|
|
|
try {
|
|
await peer.goto("/");
|
|
await expectShellReady(peer);
|
|
await ensureOwnRecord(peer);
|
|
|
|
// A switch driven from a request context that runs no client journal at
|
|
// all — the "raw or external" case the cooperative layer cannot see.
|
|
await openSwitcher(peer);
|
|
const entry = switcherEntry(peer);
|
|
await expect(entry).toBeVisible();
|
|
const accountId = await entry.getAttribute("data-account-id");
|
|
expect(accountId).not.toBeNull();
|
|
await peer.keyboard.press("Escape");
|
|
|
|
const response = await context.request.post("/api/account/switch", {
|
|
data: { accountId },
|
|
});
|
|
expect(response.status()).toBe(200);
|
|
|
|
// The tab is now asserting a context the row has left — but observing
|
|
// the refusal takes one deliberate step, and the reason is worth stating
|
|
// because it is a property of the design rather than a quirk of the test.
|
|
//
|
|
// `GET /api/auth/me` is UNFENCED (it is the reconciliation bootstrap and
|
|
// must answer while the context is unknown) and it is one of the two
|
|
// responses a client may ADOPT from. So whenever the app happens to
|
|
// re-read `/me` before it reads a record, it silently adopts the new
|
|
// context and no stale assertion is ever sent. That is correct — and it
|
|
// means a test that just navigates observes nothing, which is how the
|
|
// first draft of this case passed while proving nothing.
|
|
//
|
|
// The window the fence exists for is the opposite ordering: a suspended
|
|
// peer whose RECORD read goes out before any reconciliation. Holding
|
|
// `/me` produces exactly that ordering, deterministically.
|
|
// Held exactly once, and never unrouted: calling `unroute` while a
|
|
// handler is still parked inside its `await` abandons that route, and
|
|
// the `continue()` on the far side then throws "Route is already
|
|
// handled". Letting the handler fall through after the first hold keeps
|
|
// one owner for the route from start to finish.
|
|
const heldMe: { release: (() => void) | null; done: boolean } = {
|
|
release: null,
|
|
done: false,
|
|
};
|
|
await peer.route("**/api/auth/me", async (route) => {
|
|
if (heldMe.done) {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
heldMe.done = true;
|
|
await new Promise<void>((resolve) => {
|
|
heldMe.release = resolve;
|
|
});
|
|
await route.continue();
|
|
});
|
|
|
|
const soft = peer.locator('aside[aria-label="Sidebar"]');
|
|
await expect(soft).toBeVisible();
|
|
await soft.getByRole("link", { name: "Measurements" }).click();
|
|
|
|
await expect
|
|
.poll(() => refusals.length, { timeout: 20_000 })
|
|
.toBeGreaterThan(0);
|
|
|
|
// Let the reconciliation through and confirm the tab recovers rather
|
|
// than sitting on the refusal.
|
|
heldMe.release?.();
|
|
|
|
// Reconciled, not evicted: the tab ends up INSIDE the record the
|
|
// external switch put it in, with a usable shell.
|
|
await expect(
|
|
peer.locator('[data-slot="shared-record-banner"]'),
|
|
).toBeVisible({ timeout: 20_000 });
|
|
await expectShellReady(peer);
|
|
} finally {
|
|
// Hand the shared session back to its own record.
|
|
//
|
|
// These fixtures' storage state is shared with the other sharing
|
|
// journeys, and every test here deliberately moves the session's record
|
|
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
|
// that has nothing to do with it — which is how a green file and a red
|
|
// suite happen at the same time.
|
|
await context.request
|
|
.post("/api/account/switch", { data: { accountId: null } })
|
|
.catch(() => {});
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({
|
|
browser,
|
|
}) => {
|
|
const context = await browser.newContext({
|
|
storageState: FENCE_STORAGE_STATE_PATH,
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
try {
|
|
// The intercept goes on BEFORE the first navigation. Installed after the
|
|
// page had already loaded, it matched nothing — the only request for that
|
|
// path had come and gone, and the case passed while observing nothing.
|
|
//
|
|
// And it holds `/api/measurements`, a FENCED record read. The first
|
|
// version held `/api/auth/me` because that one is alive on every boot —
|
|
// but `/me` is an actor surface, the fence never runs on it, and it
|
|
// therefore echoes no record context at all. Holding it captured a null
|
|
// echo forever, which is the same "observed nothing" failure wearing a
|
|
// different path.
|
|
const held: {
|
|
armed: boolean;
|
|
release: (() => void) | null;
|
|
epoch: string | null;
|
|
scope: string | null;
|
|
} = { armed: false, release: null, epoch: null, scope: null };
|
|
|
|
await page.route("**/api/measurements**", async (route) => {
|
|
if (!held.armed) {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
held.armed = false;
|
|
const response = await route.fetch();
|
|
held.epoch = response.headers()["x-healthlog-record-epoch"] ?? null;
|
|
held.scope = response.headers()["x-healthlog-record-scope"] ?? null;
|
|
await new Promise<void>((resolve) => {
|
|
held.release = resolve;
|
|
});
|
|
await route.fulfill({ response }).catch(() => {});
|
|
});
|
|
|
|
await page.goto("/");
|
|
await expectShellReady(page);
|
|
await ensureOwnRecord(page);
|
|
|
|
const accountId = await enterRecord(page);
|
|
|
|
// Arm the hold, then make the app issue the read inside the record.
|
|
held.armed = true;
|
|
const sidebar = page.locator('aside[aria-label="Sidebar"]');
|
|
await expect(sidebar).toBeVisible();
|
|
await sidebar
|
|
.getByRole("link", { name: "Measurements" })
|
|
.click({ noWaitAfter: true });
|
|
|
|
await expect
|
|
.poll(() => held.release !== null, { timeout: 20_000 })
|
|
.toBe(true);
|
|
|
|
// The response was admitted under the record's context and says so.
|
|
// Proof the intercept saw a real fenced read rather than matching
|
|
// nothing, which is the whole failure mode this case kept falling into.
|
|
expect(held.epoch).toMatch(/^\d+$/);
|
|
expect(held.scope).toBe(accountId);
|
|
|
|
// Now move the context out from under it, and only then let it land.
|
|
// "Out from under it" is observable: the exit posts
|
|
// `/api/account/switch`, so poll for that request having been issued
|
|
// instead of sleeping and hoping the click got that far (C3).
|
|
let exitSwitchIssued = false;
|
|
await page.route("**/api/account/switch", async (route) => {
|
|
exitSwitchIssued = true;
|
|
await route.continue();
|
|
});
|
|
const leaving = page
|
|
.locator('[data-slot="shared-record-banner-exit"]')
|
|
.click({ noWaitAfter: true });
|
|
await expect.poll(() => exitSwitchIssued, { timeout: 20_000 }).toBe(true);
|
|
held.release?.();
|
|
await leaving.catch(() => {});
|
|
|
|
// The page settles on the record it is actually in — its own — without
|
|
// painting the one the held response described.
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-banner"]'),
|
|
).toHaveCount(0, { timeout: 20_000 });
|
|
await expectShellReady(page);
|
|
} finally {
|
|
// Hand the shared session back to its own record.
|
|
await context.request
|
|
.post("/api/account/switch", { data: { accountId: null } })
|
|
.catch(() => {});
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|