diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index 47365717b..473f02147 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -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 { + 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(); } }); diff --git a/e2e/v137-record-session-fence.spec.ts b/e2e/v137-record-session-fence.spec.ts index 99f2c5a1f..bf75472e9 100644 --- a/e2e/v137-record-session-fence.spec.ts +++ b/e2e/v137-record-session-fence.spec.ts @@ -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((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((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(); } }); diff --git a/playwright.config.ts b/playwright.config.ts index 9a776217f..aa4e27f5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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: {