291 lines
7.9 KiB
TypeScript
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();
|
|
}
|
|
});
|