test(02-12.1): make the deferred acceptance cases prove their claims

Every one of the three observed nothing and passed anyway.

- AC-04 held /api/auth/me so the record read goes first: /me is unfenced
  and adoptable, so the client silently re-adopted before ever sending a
  stale assertion
- AC-06 installs its intercept before the request and holds a FENCED
  read; /api/auth/me is an actor surface and echoes no context at all
- AC-07 moves to its own spec and a project with the service worker
  enabled, and reads the claim off the cache: with workers blocked the
  data cache is never populated and its positive control could not pass
- restore the shared session after each test, and start from a known
  record, so these cannot fail the specs that run beside them
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 21:31:23 +02:00
1 parent 1d575cf1ae
commit 9c99ec34d6
3 files changed
+371 -90

No files matched your search

+149 -39
View File
@@ -28,19 +28,33 @@ import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
* 2. only current-version caches exist, which is the eviction the fence's
* design relies on to make "a response with no echo" safe;
* 3. the switch empties the data cache before it reloads;
* 4. offline inside the target record, no previous-record bytes are painted;
* 5. and — the paired positive control — the target's own data IS painted
* once it has been fetched inside this record, so 4 cannot pass on a
* blank page.
* 4. every response now on disk carries the TARGET's record scope and never
* the previous one — the claim, read off the disk layer itself rather
* than inferred from what a page did or did not paint;
* 5. and offline, that cache is what answers a record read, still carrying
* the target's context. The non-empty scope set is the paired positive
* control: an empty cache would satisfy "no previous-record bytes" while
* proving nothing.
*/
const WARM_READS = [
"/api/dashboard/snapshot",
"/api/measurements",
"/api/medications",
] as const;
/**
* Pages whose reads the service worker is configured to hold on disk
* (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`,
* `/api/measurements` and `/api/medications`). Visited rather than fetched, so
* the requests are the app's own and carry the record-session assertion.
*/
const WARM_PAGES = ["/", "/measurements", "/medications"] as const;
const TARGET_USERNAME = "e2e-scope-labs";
/**
* 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();
@@ -67,6 +81,30 @@ async function dataCacheState(page: Page) {
});
}
/**
* The record context every cached response was stored WITH.
*
* This is the disk-layer claim, read off the disk layer. A cached record
* response carries the echo it was served under, so if a previous record's
* bytes were still reachable they would show up here as that record's scope.
*/
async function cachedScopes(page: Page): Promise<string[]> {
return page.evaluate(async () => {
const scopes: string[] = [];
for (const name of (await caches.keys()).filter((n) =>
n.startsWith("healthlog-data-"),
)) {
const cache = await caches.open(name);
for (const request of await cache.keys()) {
const cached = await cache.match(request);
const scope = cached?.headers.get("x-healthlog-record-scope");
if (scope) scopes.push(scope);
}
}
return scopes;
});
}
test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
browser,
@@ -86,6 +124,13 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
// The session is shared with the other sharing journeys, so "not inside
// a record" is not something a fresh page can assume.
const banner = page.locator('[data-slot="shared-record-banner"]');
if ((await banner.count()) > 0) {
await page.locator('[data-slot="shared-record-banner-exit"]').click();
await expect(banner).toHaveCount(0);
}
// Wait for the worker to control the page — until it does, nothing it
// would cache is going through it.
@@ -95,11 +140,24 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
{ timeout: 20_000 },
);
// Warm the reads the SW is configured to hold on disk.
for (const path of WARM_READS) {
await page.evaluate(async (p) => {
await fetch(p).catch(() => {});
}, path);
// Warm the disk cache by VISITING the pages, not by issuing bare
// fetches. A bare `fetch` in `page.evaluate` carries no record-session
// assertion, so on a session that has been inside a shared record the
// fence refuses it and nothing cacheable comes back — the cache filled
// from the app's own reads instead, and those were the ones that happened
// to resolve no record scope. Driving the app is what puts real,
// echo-carrying record responses on disk.
for (const path of WARM_PAGES) {
await page.goto(path);
// Spelled out rather than left inside `expectShellReady`, because the
// 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 reader.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
}
await expect
.poll(async () => (await dataCacheState(page)).entries, {
@@ -128,25 +186,38 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
);
await expect(entry).toBeVisible();
const targetAccountId = await entry.getAttribute("data-account-id");
expect(targetAccountId).not.toBeNull();
await entry.click();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// (3) The switch wipes the disk layers before it reloads, so nothing
// from the previous record survives into the new one.
await expect
.poll(async () => (await dataCacheState(page)).entries, {
timeout: 15_000,
})
.toBe(0);
// (3) NOT asserted: "the data cache is momentarily empty".
//
// The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch`
// runs before the reload — but the reload immediately refills them from
// the target's own reads, so an empty cache is a state that exists for a
// few milliseconds and cannot be sampled reliably. Polling for it made
// this case fail for a reason that had nothing to do with the claim.
//
// The claim is asserted directly below instead, and it is the stronger
// statement anyway: what matters is not that the cache was briefly empty
// but that nothing in it belongs to the previous record.
// Fetch inside the TARGET record so there is something of its own on
// Visit inside the TARGET record so there is something of its own on
// disk to find.
for (const path of WARM_READS) {
await page.evaluate(async (p) => {
await fetch(p).catch(() => {});
}, path);
for (const path of WARM_PAGES) {
await page.goto(path);
// Spelled out rather than left inside `expectShellReady`, because the
// 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 reader.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
}
await expect
.poll(async () => (await dataCacheState(page)).entries, {
@@ -154,20 +225,59 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
})
.toBeGreaterThan(0);
// (4) + (5) Offline, inside the target record: the shell still paints
// and it still says which record it is in. The banner is the paired
// positive control — an offline page that rendered nothing at all would
// satisfy "no previous-record bytes" while proving nothing.
await context.setOffline(true);
await page.reload().catch(() => {});
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toContainText(TARGET_USERNAME);
// (4) + (5) The disk layer now holds the TARGET's bytes and only those,
// read off the disk layer itself.
//
// The first draft asserted the shared-record banner after an offline
// reload. That could never pass and had nothing to do with the claim:
// `/api/auth/me` is not a cached read, so offline it fails, `useAuth`
// errors and the shell paints its offline gate rather than a banner. The
// banner's absence says nothing about what is on disk.
//
// What IS on disk is checkable, and it is the whole point: every cached
// record response carries the echo it was served under, so a previous
// record's bytes surviving the switch would appear here as that record's
// scope.
const scopes = await cachedScopes(page);
// POSITIVE CONTROL: something really is cached with a context, so the
// assertion below is about a non-empty set.
expect(scopes.length).toBeGreaterThan(0);
for (const scope of scopes) {
expect(scope).toBe(targetAccountId);
}
expect(scopes).not.toContain("self");
// And the target's own record read really is one of the entries on disk,
// so the scope assertion above is about the reads that matter rather
// than about some incidental cached response.
const cachedUrls = await page.evaluate(async () => {
const urls: string[] = [];
for (const name of (await caches.keys()).filter((n) =>
n.startsWith("healthlog-data-"),
)) {
for (const request of await (await caches.open(name)).keys()) {
urls.push(new URL(request.url).pathname);
}
}
return urls;
});
expect(cachedUrls).toContain("/api/measurements");
// NOT asserted: that a bare `fetch` from page context can read those
// bytes back while offline. Since v1.37.0 an echoing response also
// carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and
// the Cache API honours `Vary` — so a request WITHOUT the fence headers
// does not match the stored entry at all. That is the correct behaviour
// and a second line of defence worth naming: bytes cached under one
// record context cannot be retrieved by a request asserting a different
// one, or none. The app's own reads carry the headers and do match.
} finally {
await context.setOffline(false);
// Hand the shared session back to its own record; see the note in
// `v137-record-session-fence.spec.ts`.
await context.request
.post("/api/account/switch", { data: { accountId: null } })
.catch(() => {});
await context.close();
}
});
+214 -50
View File
@@ -45,7 +45,16 @@ const RECORD_READS = [
const EPOCH_HEADER = "x-healthlog-record-epoch";
const SCOPE_HEADER = "x-healthlog-record-scope";
const TARGET_USERNAME = "e2e-scope-labs";
/**
* 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();
@@ -66,6 +75,22 @@ function deferred() {
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);
}
/** Wait for the shell to be usable again — the paired positive control. */
async function expectShellReady(page: Page) {
await expect(
@@ -94,6 +119,11 @@ 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: SCOPE_DELEGATE_STORAGE_STATE_PATH,
});
@@ -105,6 +135,10 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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);
@@ -112,12 +146,18 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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();
// 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();
@@ -139,7 +179,6 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
expect(inRecordEpoch).toBeGreaterThan(0);
// ── and the same on the way out ───────────────────────────────────
peerAssertions.length = 0;
await initiator
.locator('[data-slot="shared-record-banner-exit"]')
.click();
@@ -147,11 +186,21 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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);
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);
expect(peerAssertions.length).toBeGreaterThan(0);
for (const assertion of peerAssertions) {
@@ -161,6 +210,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
expect(Number(assertion.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();
}
});
@@ -180,6 +239,7 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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
@@ -212,6 +272,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
).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();
}
});
@@ -228,6 +298,10 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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);
@@ -251,6 +325,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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();
}
});
@@ -262,10 +346,25 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
storageState: SCOPE_DELEGATE_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.
@@ -281,19 +380,53 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
});
expect(response.status()).toBe(200);
// The tab is now asserting a context the row has left. Force a REAL
// record read: a synthetic `focus` event fires no refetch, so the first
// draft of this case observed nothing and passed for the wrong reason.
// A client-side navigation mounts a page whose cells fetch.
const refused = peer.waitForResponse(
(r) =>
(RECORD_READS as readonly string[]).includes(
new URL(r.url()).pathname,
) && r.status() === 409,
{ timeout: 20_000 },
);
await peer.goto("/medications");
await refused;
// 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.
@@ -302,6 +435,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
).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();
}
});
@@ -316,66 +459,87 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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 at all.
let heldEcho: string | null = null;
let heldScope: string | null = null;
const held: { release: (() => void) | null } = { release: null };
let holdArmed = false;
// 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/auth/me", async (route) => {
// `/api/auth/me` is the one read EVERY record keeps alive, on every
// boot, regardless of which sections a narrowed grant opens — the
// previous choice was dormant under the target's grant, so it never
// resolved across the switch.
if (!holdArmed) {
await page.route("**/api/measurements**", async (route) => {
if (!held.armed) {
await route.continue();
return;
}
holdArmed = false;
held.armed = false;
const response = await route.fetch();
heldEcho = response.headers()["x-healthlog-record-epoch"] ?? null;
heldScope = response.headers()["x-healthlog-record-scope"] ?? null;
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 });
await route.fulfill({ response }).catch(() => {});
});
await page.goto("/");
await expectShellReady(page);
await ensureOwnRecord(page);
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({ timeout: 20_000 });
// Now arm the hold and make the app issue the read again, so a response
// is genuinely in flight while the context moves back.
holdArmed = true;
const leaving = page
.locator('[data-slot="shared-record-banner-exit"]')
// 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.
const leaving = page
.locator('[data-slot="shared-record-banner-exit"]')
.click({ noWaitAfter: true });
await page.waitForTimeout(500);
held.release?.();
await leaving.catch(() => {});
// The held response carried a real echo — proof the intercept saw the
// request rather than matching nothing.
expect(heldEcho).not.toBeNull();
expect(heldScope).not.toBeNull();
// And the page settles on the record it is actually in, without painting
// the one the held response described.
// 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();
}
});
+8 -1
View File
@@ -117,7 +117,14 @@ export default defineConfig({
testIgnore: [
"v137-sharing-managed-profiles.spec.ts",
"v137-sharing-managed-profiles-a11y.spec.ts",
// Runs only in the service-worker project below.
// v1.37.0 — the record-session fence specs carry the same persistent
// server-side stamp, and more sharply: the fence's whole subject is
// one session's record context and its monotonic epoch. Two projects
// driving the same session concurrently would move that epoch under
// each other, so the failure would be the harness racing itself rather
// than anything about the fence.
"v137-record-session-fence.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
],
use: {