Files
tech-log-frontend/tests/browser-capabilities/cross-context-invalidation.spec.ts
T

291 lines
7.9 KiB
TypeScript

import {
expect,
test,
type Page,
} from "../support/browser/strict-browser-test.ts";
const TOPIC = "sample-topic-alpha";
const TOPIC_VERSION = 1;
const STATE_KEY = "__crossContextInvalidationCapability";
const STORAGE_PULSE_KEY =
"ca-frontend:cache-invalidation:v1:pulse";
type BrowserTransportMode = "BROADCAST" | "STORAGE";
type BrowserDeliverySnapshot = Readonly<{
ordering: string;
sequence: number;
topic: string;
transport: string;
}>;
type BrowserRuntimeSnapshot = Readonly<{
status: string;
deliveries: readonly BrowserDeliverySnapshot[];
pulseRetained: boolean;
}>;
async function installRuntime(
page: Page,
mode: BrowserTransportMode,
cacheEpoch: string,
): Promise<string> {
return await page.evaluate(
async ({
currentCacheEpoch,
currentMode,
stateKey,
topic,
topicVersion,
}) => {
const modulePath =
"/src/adapters/cross-context-invalidation/index.ts";
const { createBrowserCrossContextInvalidationFromHost } =
(await import(
/* @vite-ignore */ modulePath
)) as typeof import("../../src/adapters/cross-context-invalidation/index.ts");
const host: Record<string, unknown> =
currentMode === "BROADCAST"
? (globalThis as unknown as Record<string, unknown>)
: {
crypto: globalThis.crypto,
localStorage: globalThis.localStorage,
addEventListener:
globalThis.addEventListener.bind(globalThis),
removeEventListener:
globalThis.removeEventListener.bind(globalThis),
};
const runtime = createBrowserCrossContextInvalidationFromHost({
host,
cacheEpoch: currentCacheEpoch,
topics: [{ topic, topicVersion }],
});
if (!runtime) {
throw new Error(
"Cross-context invalidation runtime is unavailable.",
);
}
const deliveries: BrowserDeliverySnapshot[] = [];
runtime.subscribe((delivery) => {
deliveries.push({
ordering: delivery.ordering,
sequence: delivery.event.sequence,
topic: delivery.event.topic,
transport: delivery.transport,
});
});
Reflect.set(globalThis, stateKey, { deliveries, runtime });
return runtime.getStatus();
},
{
currentCacheEpoch: cacheEpoch,
currentMode: mode,
stateKey: STATE_KEY,
topic: TOPIC,
topicVersion: TOPIC_VERSION,
},
);
}
async function publish(page: Page) {
return await page.evaluate(
({ stateKey, topic, topicVersion }) => {
const state = Reflect.get(globalThis, stateKey) as
| {
runtime: {
publish(input: {
topic: string;
topicVersion: number;
}): unknown;
};
}
| undefined;
if (!state) throw new Error("Capability runtime is not installed.");
return state.runtime.publish({ topic, topicVersion });
},
{
stateKey: STATE_KEY,
topic: TOPIC,
topicVersion: TOPIC_VERSION,
},
);
}
async function snapshot(page: Page): Promise<BrowserRuntimeSnapshot> {
return await page.evaluate(
({ pulseKey, stateKey }) => {
const state = Reflect.get(globalThis, stateKey) as
| {
deliveries: BrowserDeliverySnapshot[];
runtime: { getStatus(): string };
}
| undefined;
if (!state) throw new Error("Capability runtime is not installed.");
return {
status: state.runtime.getStatus(),
deliveries: structuredClone(state.deliveries),
pulseRetained: localStorage.getItem(pulseKey) !== null,
};
},
{ pulseKey: STORAGE_PULSE_KEY, stateKey: STATE_KEY },
);
}
async function closeRuntime(page: Page): Promise<string | null> {
return await page.evaluate((stateKey) => {
const state = Reflect.get(globalThis, stateKey) as
| { runtime: { close(): void; getStatus(): string } }
| undefined;
if (!state) return null;
state.runtime.close();
return state.runtime.getStatus();
}, STATE_KEY);
}
async function disposeRuntime(page: Page): Promise<void> {
await page.evaluate(
({ pulseKey, stateKey }) => {
const state = Reflect.get(globalThis, stateKey) as
| { runtime: { close(): void } }
| undefined;
state?.runtime.close();
localStorage.removeItem(pulseKey);
Reflect.deleteProperty(globalThis, stateKey);
},
{ pulseKey: STORAGE_PULSE_KEY, stateKey: STATE_KEY },
);
}
test("delivers invalidation through native BroadcastChannel and cleans the receiver", async ({
page,
}) => {
const secondPage = await page.context().newPage();
const cacheEpoch = `browser.primary.${Date.now()}`;
try {
await Promise.all([page.goto("/"), secondPage.goto("/")]);
await page.evaluate(
(pulseKey) => localStorage.removeItem(pulseKey),
STORAGE_PULSE_KEY,
);
const statuses = await Promise.all([
installRuntime(page, "BROADCAST", cacheEpoch),
installRuntime(secondPage, "BROADCAST", cacheEpoch),
]);
expect(statuses).toEqual([
"ACTIVE_BROADCAST",
"ACTIVE_BROADCAST",
]);
expect(await publish(page)).toEqual({
ok: true,
transport: "BROADCAST",
});
await expect
.poll(async () => (await snapshot(secondPage)).deliveries)
.toEqual([
{
ordering: "NEXT",
sequence: 1,
topic: TOPIC,
transport: "BROADCAST",
},
]);
expect((await snapshot(page)).deliveries).toEqual([]);
expect(await closeRuntime(secondPage)).toBe("CLOSED");
expect(await publish(page)).toEqual({
ok: true,
transport: "BROADCAST",
});
await page.waitForTimeout(100);
expect(await snapshot(secondPage)).toEqual({
status: "CLOSED",
deliveries: [
{
ordering: "NEXT",
sequence: 1,
topic: TOPIC,
transport: "BROADCAST",
},
],
pulseRetained: false,
});
} finally {
await Promise.allSettled([
disposeRuntime(page),
disposeRuntime(secondPage),
]);
await secondPage.close();
}
});
test("falls back to native localStorage events and removes the pulse during cleanup", async ({
page,
}) => {
const secondPage = await page.context().newPage();
const cacheEpoch = `browser.fallback.${Date.now()}`;
try {
await Promise.all([page.goto("/"), secondPage.goto("/")]);
await page.evaluate(
(pulseKey) => localStorage.removeItem(pulseKey),
STORAGE_PULSE_KEY,
);
const statuses = await Promise.all([
installRuntime(page, "STORAGE", cacheEpoch),
installRuntime(secondPage, "STORAGE", cacheEpoch),
]);
expect(statuses).toEqual([
"ACTIVE_STORAGE_FALLBACK",
"ACTIVE_STORAGE_FALLBACK",
]);
expect(await publish(page)).toEqual({
ok: true,
transport: "STORAGE",
});
await expect
.poll(async () => (await snapshot(secondPage)).deliveries)
.toEqual([
{
ordering: "NEXT",
sequence: 1,
topic: TOPIC,
transport: "STORAGE",
},
]);
expect(await snapshot(page)).toMatchObject({
deliveries: [],
pulseRetained: false,
});
expect(await snapshot(secondPage)).toMatchObject({
pulseRetained: false,
});
expect(await closeRuntime(secondPage)).toBe("CLOSED");
expect(await publish(page)).toEqual({
ok: true,
transport: "STORAGE",
});
await page.waitForTimeout(100);
expect(await snapshot(secondPage)).toEqual({
status: "CLOSED",
deliveries: [
{
ordering: "NEXT",
sequence: 1,
topic: TOPIC,
transport: "STORAGE",
},
],
pulseRetained: false,
});
} finally {
await Promise.allSettled([
disposeRuntime(page),
disposeRuntime(secondPage),
]);
await secondPage.close();
}
});