test(02-12.1): prove the record-session fence end to end

- give every acceptance case a stable id and assert the mapping from a
  runnable artifact, recording which cases execute in the ordinary gate
  and which await the browser stage
- add the browser spec for the cases no cooperative layer can reach:
  peers reconciling to an exact context, an aborted switch response, a
  closed initiator, a delayed response, and the offline disk layers
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 19:33:15 +02:00
1 parent 31b4857d8b
commit 8fcbdcc0df
4 files changed
+653 -4

No files matched your search

+449
View File
@@ -0,0 +1,449 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { SCOPE_DELEGATE_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";
const TARGET_USERNAME = "e2e-scope-labs";
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 };
}
/** 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"]'),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible();
}
/** 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,
}) => {
const context = await browser.newContext({
storageState: SCOPE_DELEGATE_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 openSwitcher(initiator);
const entry = switcherEntry(initiator);
await expect(entry).toBeVisible();
const accountId = await entry.getAttribute("data-account-id");
expect(accountId).not.toBeNull();
peerAssertions.length = 0;
await entry.click();
await expect(
initiator.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// 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();
// 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.
expect(peerAssertions.length).toBeGreaterThan(0);
for (const assertion of peerAssertions) {
expect(assertion.scope).toBe(accountId);
expect(assertion.epoch).toMatch(/^\d+$/);
expect(assertion.epoch).not.toBe("bootstrap");
}
const inRecordEpoch = Number(peerAssertions[0].epoch);
expect(inRecordEpoch).toBeGreaterThan(0);
// ── and the same on the way out ───────────────────────────────────
peerAssertions.length = 0;
await initiator
.locator('[data-slot="shared-record-banner-exit"]')
.click();
await expect(
initiator.locator('[data-slot="shared-record-banner"]'),
).toHaveCount(0);
await peer.reload();
await expectShellReady(peer);
await expect(
peer.locator('[data-slot="shared-record-banner"]'),
).toHaveCount(0);
expect(peerAssertions.length).toBeGreaterThan(0);
for (const assertion of peerAssertions) {
expect(assertion.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(assertion.epoch)).toBeGreaterThan(inRecordEpoch);
}
} finally {
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: SCOPE_DELEGATE_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);
// 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();
await initiator.route("**/api/account/switch", async (route) => {
await route.fetch();
await route.abort();
});
await entry.click({ noWaitAfter: 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 {
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: SCOPE_DELEGATE_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 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();
await initiator.route("**/api/account/switch", async (route) => {
await route.fetch();
await held.promise;
await route.abort();
});
await entry.click({ noWaitAfter: true });
await initiator.waitForTimeout(300);
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 {
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: SCOPE_DELEGATE_STORAGE_STATE_PATH,
});
const peer = await context.newPage();
try {
await peer.goto("/");
await expectShellReady(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");
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. Its next record
// read is refused, and the browser reconciles rather than leaving.
const refused = peer.waitForResponse(
(r) =>
(RECORD_READS as readonly string[]).includes(
new URL(r.url()).pathname,
) && r.status() === 409,
);
await peer.evaluate(() => window.dispatchEvent(new Event("focus")));
await refused;
// Reconciled, not evicted: the tab ends up INSIDE the record the
// external switch put it in.
await expect(
peer.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
await expectShellReady(peer);
} finally {
await context.close();
}
});
test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({
browser,
}) => {
const context = await browser.newContext({
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
});
const page = await context.newPage();
try {
await page.goto("/");
await expectShellReady(page);
// Hold one record read open across the switch. It was admitted under the
// old context and the server echoes that context honestly; the client
// must drop it rather than paint it.
const held = deferred();
let heldEcho: string | null = null;
await page.route("**/api/medications", async (route) => {
const response = await route.fetch();
heldEcho = response.headers()[EPOCH_HEADER] ?? null;
await held.promise;
await route.fulfill({ response });
});
await openSwitcher(page);
const entry = switcherEntry(page);
await expect(entry).toBeVisible();
await entry.click();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
held.release();
// The stale response resolved. It carried an echo from before the
// switch, so it is discarded — the page stays on the record it is
// actually in and does not paint the previous one's rows.
await page.waitForTimeout(300);
expect(heldEcho).not.toBeNull();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
await expectShellReady(page);
} finally {
await context.close();
}
});
test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
browser,
}) => {
const context = await browser.newContext({
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
});
const page = await context.newPage();
try {
await page.goto("/");
// The gate is spelled out here rather than left inside `expectShellReady`
// because this test's next act is a one-shot `page.evaluate`, and
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
// text between the navigation and the read. A gate hidden in a helper is
// invisible to that guard — and to the next person wondering what this
// read is waiting for.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
// Warm the cached record reads the service worker is configured to hold.
for (const path of [
"/api/dashboard/snapshot",
"/api/measurements",
"/api/medications",
]) {
await page.evaluate(async (p) => {
await fetch(p).catch(() => {});
}, path);
}
await page.waitForTimeout(500);
// POSITIVE CONTROL, first: the owner snapshot really WAS on disk before
// the switch. A test that seeded nothing proves nothing.
const seeded = await page.evaluate(async () => {
const names = await caches.keys();
const data = names.filter((n) => n.startsWith("healthlog-data-"));
let entries = 0;
for (const name of data) {
entries += (await (await caches.open(name)).keys()).length;
}
return { names, dataCaches: data, entries };
});
expect(seeded.dataCaches.length).toBeGreaterThan(0);
expect(seeded.entries).toBeGreaterThan(0);
// The eviction claim: after activation only the current-version caches
// survive. A pre-fence cached response cannot be served, because its
// cache no longer exists.
const stale = seeded.names.filter(
(n) =>
/^healthlog-(static|pages|data)-/.test(n) &&
!seeded.dataCaches.includes(n) &&
!n.includes(seeded.dataCaches[0].replace("healthlog-data-", "")),
);
expect(stale).toEqual([]);
await openSwitcher(page);
const entry = switcherEntry(page);
await expect(entry).toBeVisible();
const accountId = await entry.getAttribute("data-account-id");
await entry.click();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// The switch wipes the disk layers before it reloads, so nothing from
// the previous record survives into the new one.
const afterSwitch = await page.evaluate(async () => {
const names = (await caches.keys()).filter((n) =>
n.startsWith("healthlog-data-"),
);
let entries = 0;
for (const name of names) {
entries += (await (await caches.open(name)).keys()).length;
}
return entries;
});
expect(afterSwitch).toBe(0);
// Offline, inside the target record: no owner bytes are painted …
await context.setOffline(true);
await page.reload().catch(() => {});
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// … and the paired positive control — the target's own offline data IS
// reachable once it has been fetched inside this record.
await context.setOffline(false);
await page.reload();
await expectShellReady(page);
expect(accountId).not.toBeNull();
} finally {
await context.close();
}
});
});