652 lines
19 KiB
TypeScript
652 lines
19 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
CACHE_INVALIDATION_PROTOCOL_VERSION,
|
|
CACHE_INVALIDATION_WIRE_LIMITS,
|
|
decodeCacheInvalidationWireEvent,
|
|
parseCacheInvalidationWireEvent,
|
|
type CacheInvalidationWireEvent,
|
|
} from "../../src/contracts/cache-invalidation.ts";
|
|
import {
|
|
createBrowserCrossContextInvalidation,
|
|
createBrowserCrossContextInvalidationFromHost,
|
|
type BroadcastChannelFacade,
|
|
type BroadcastMessageListener,
|
|
type BrowserCrossContextInvalidationDependencies,
|
|
type CrossContextInvalidationDelivery,
|
|
type CrossContextInvalidationObservation,
|
|
type StorageEventTargetFacade,
|
|
type StoragePulseFacade,
|
|
type StoragePulseListener,
|
|
} from "../../src/adapters/cross-context-invalidation/index.ts";
|
|
|
|
const NOW = 1_000_000;
|
|
const CACHE_EPOCH = "cache-epoch-0001";
|
|
const TOPIC = "sample-topic-alpha";
|
|
const CHANNEL_NAME = "cache-invalidation-v1";
|
|
const STORAGE_PULSE_KEY = "ca-frontend:cache-invalidation:v1:pulse";
|
|
|
|
class FakeBroadcastNetwork {
|
|
readonly channels: FakeBroadcastChannel[] = [];
|
|
readonly messages: unknown[] = [];
|
|
|
|
createChannel = (name: string): FakeBroadcastChannel => {
|
|
const channel = new FakeBroadcastChannel(name, this);
|
|
this.channels.push(channel);
|
|
return channel;
|
|
};
|
|
|
|
emit(value: unknown): void {
|
|
this.messages.push(value);
|
|
for (const channel of this.channels) channel.emit(value);
|
|
}
|
|
}
|
|
|
|
class FakeBroadcastChannel implements BroadcastChannelFacade {
|
|
readonly listeners = new Set<BroadcastMessageListener>();
|
|
closed = false;
|
|
failPost = false;
|
|
closeCount = 0;
|
|
removeCount = 0;
|
|
|
|
constructor(
|
|
readonly name: string,
|
|
private readonly network: FakeBroadcastNetwork,
|
|
) {}
|
|
|
|
postMessage(value: unknown): void {
|
|
if (this.closed || this.failPost) {
|
|
throw new DOMException("Broadcast failed", "InvalidStateError");
|
|
}
|
|
// Deliberately includes the sender. Native BroadcastChannel can still
|
|
// deliver through another same-context channel, so the adapter must use
|
|
// source identity rather than relying on provider echo behavior.
|
|
this.network.emit(value);
|
|
}
|
|
|
|
addEventListener(
|
|
_type: "message",
|
|
listener: BroadcastMessageListener,
|
|
): void {
|
|
this.listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(
|
|
_type: "message",
|
|
listener: BroadcastMessageListener,
|
|
): void {
|
|
this.removeCount += 1;
|
|
this.listeners.delete(listener);
|
|
}
|
|
|
|
close(): void {
|
|
this.closeCount += 1;
|
|
this.closed = true;
|
|
this.listeners.clear();
|
|
}
|
|
|
|
emit(value: unknown): void {
|
|
if (this.closed) return;
|
|
for (const listener of [...this.listeners]) {
|
|
listener({ data: value });
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeStorageEventTarget implements StorageEventTargetFacade {
|
|
readonly listeners = new Set<StoragePulseListener>();
|
|
removeCount = 0;
|
|
|
|
addEventListener(
|
|
_type: "storage",
|
|
listener: StoragePulseListener,
|
|
): void {
|
|
this.listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(
|
|
_type: "storage",
|
|
listener: StoragePulseListener,
|
|
): void {
|
|
this.removeCount += 1;
|
|
this.listeners.delete(listener);
|
|
}
|
|
|
|
emit(key: string | null, newValue: string | null): void {
|
|
for (const listener of [...this.listeners]) {
|
|
listener({ key, newValue });
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeStorageBus {
|
|
readonly values = new Map<string, string>();
|
|
readonly targets = new Set<FakeStorageEventTarget>();
|
|
|
|
createEndpoint(): Readonly<{
|
|
storage: FakeStorageEndpoint;
|
|
target: FakeStorageEventTarget;
|
|
}> {
|
|
const target = new FakeStorageEventTarget();
|
|
this.targets.add(target);
|
|
return Object.freeze({
|
|
storage: new FakeStorageEndpoint(this, target),
|
|
target,
|
|
});
|
|
}
|
|
|
|
set(
|
|
owner: FakeStorageEventTarget,
|
|
key: string,
|
|
value: string,
|
|
): void {
|
|
this.values.set(key, value);
|
|
for (const target of this.targets) {
|
|
if (target !== owner) target.emit(key, value);
|
|
}
|
|
}
|
|
|
|
remove(owner: FakeStorageEventTarget, key: string): void {
|
|
this.values.delete(key);
|
|
for (const target of this.targets) {
|
|
if (target !== owner) target.emit(key, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeStorageEndpoint implements StoragePulseFacade {
|
|
failSet = false;
|
|
failRemove = false;
|
|
setCount = 0;
|
|
removeCount = 0;
|
|
|
|
constructor(
|
|
private readonly bus: FakeStorageBus,
|
|
private readonly owner: FakeStorageEventTarget,
|
|
) {}
|
|
|
|
setItem(key: string, value: string): void {
|
|
this.setCount += 1;
|
|
if (this.failSet) {
|
|
throw new DOMException("Storage denied", "SecurityError");
|
|
}
|
|
this.bus.set(this.owner, key, value);
|
|
}
|
|
|
|
removeItem(key: string): void {
|
|
this.removeCount += 1;
|
|
if (this.failRemove) {
|
|
throw new DOMException("Storage denied", "SecurityError");
|
|
}
|
|
this.bus.remove(this.owner, key);
|
|
}
|
|
}
|
|
|
|
function dependencies(
|
|
id: string,
|
|
overrides: Partial<BrowserCrossContextInvalidationDependencies> = {},
|
|
): BrowserCrossContextInvalidationDependencies {
|
|
let eventNumber = 0;
|
|
return {
|
|
channelName: CHANNEL_NAME,
|
|
storagePulseKey: STORAGE_PULSE_KEY,
|
|
sourceId: `${id}-source`,
|
|
sourceEpoch: `${id}-epoch`,
|
|
cacheEpoch: CACHE_EPOCH,
|
|
topics: [{ topic: TOPIC, topicVersion: 1 }],
|
|
createEventId: () =>
|
|
`${id}-event-${String(++eventNumber).padStart(8, "0")}`,
|
|
nowEpochMilliseconds: () => NOW,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function wireEvent(
|
|
overrides: Partial<CacheInvalidationWireEvent> = {},
|
|
): CacheInvalidationWireEvent {
|
|
return {
|
|
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
|
|
eventId: "remote-event-00000001",
|
|
sourceId: "remote-source-0000001",
|
|
sourceEpoch: "remote-epoch-0000001",
|
|
sequence: 1,
|
|
cacheEpoch: CACHE_EPOCH,
|
|
topic: TOPIC,
|
|
topicVersion: 1,
|
|
emittedAt: NOW,
|
|
expiresAt: NOW + 60_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("cache invalidation wire contract", () => {
|
|
const policy = {
|
|
cacheEpoch: CACHE_EPOCH,
|
|
topicVersions: { [TOPIC]: 1 },
|
|
nowEpochMilliseconds: NOW,
|
|
} as const;
|
|
|
|
it("accepts only the exact payload-free, query-key-free envelope", () => {
|
|
const accepted = parseCacheInvalidationWireEvent(wireEvent(), policy);
|
|
expect(accepted).toMatchObject({ ok: true });
|
|
if (!accepted.ok) throw new Error("Expected a valid event");
|
|
|
|
expect(Object.keys(accepted.value).sort()).toEqual([
|
|
"cacheEpoch",
|
|
"emittedAt",
|
|
"eventId",
|
|
"expiresAt",
|
|
"protocolVersion",
|
|
"sequence",
|
|
"sourceEpoch",
|
|
"sourceId",
|
|
"topic",
|
|
"topicVersion",
|
|
]);
|
|
expect(accepted.value).not.toHaveProperty("payload");
|
|
expect(accepted.value).not.toHaveProperty("queryKey");
|
|
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
{ ...wireEvent(), payload: { secret: "must-not-cross" } },
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "INVALID_ENVELOPE" });
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
{ ...wireEvent(), queryKey: ["resource", "sensitive-id"] },
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "INVALID_ENVELOPE" });
|
|
});
|
|
|
|
it("bounds bytes, protocol, allowlist, cache epoch and event lifetime", () => {
|
|
expect(
|
|
decodeCacheInvalidationWireEvent("{not-json", policy),
|
|
).toEqual({ ok: false, reason: "MALFORMED_JSON" });
|
|
expect(
|
|
decodeCacheInvalidationWireEvent(
|
|
"x".repeat(
|
|
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes + 1,
|
|
),
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "OVERSIZED" });
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
{ ...wireEvent(), protocolVersion: 2 },
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "PROTOCOL_MISMATCH" });
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
{ ...wireEvent(), topic: "unknown-topic" },
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "TOPIC_REJECTED" });
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
{ ...wireEvent(), topicVersion: 2 },
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "TOPIC_REJECTED" });
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
{ ...wireEvent(), cacheEpoch: "other-cache-epoch" },
|
|
policy,
|
|
),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: "CACHE_EPOCH_MISMATCH",
|
|
});
|
|
expect(
|
|
parseCacheInvalidationWireEvent(
|
|
wireEvent({ emittedAt: NOW - 60_000, expiresAt: NOW }),
|
|
policy,
|
|
),
|
|
).toEqual({ ok: false, reason: "EXPIRED" });
|
|
});
|
|
});
|
|
|
|
describe("browser cross-context host", () => {
|
|
it("does not inspect browser capabilities when no topic is installed", () => {
|
|
const reads: string[] = [];
|
|
const host = new Proxy<Record<string, unknown>>(
|
|
{},
|
|
{
|
|
get(_target, property) {
|
|
reads.push(String(property));
|
|
throw new DOMException("Capability access denied", "SecurityError");
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(
|
|
createBrowserCrossContextInvalidationFromHost({
|
|
host,
|
|
cacheEpoch: CACHE_EPOCH,
|
|
topics: [],
|
|
}),
|
|
).toBeUndefined();
|
|
expect(reads).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("browser cross-context invalidation transport", () => {
|
|
it("publishes one exact BroadcastChannel event and filters self echo", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
const first = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-a", {
|
|
createBroadcastChannel: network.createChannel,
|
|
}),
|
|
);
|
|
const second = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: network.createChannel,
|
|
}),
|
|
);
|
|
const local = vi.fn();
|
|
const remote = vi.fn();
|
|
first.subscribe(local);
|
|
second.subscribe(remote);
|
|
|
|
expect(first.getStatus()).toBe("ACTIVE_BROADCAST");
|
|
expect(first.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
|
ok: true,
|
|
transport: "BROADCAST",
|
|
});
|
|
expect(local).not.toHaveBeenCalled();
|
|
expect(remote).toHaveBeenCalledOnce();
|
|
expect(remote.mock.calls[0]?.[0]).toMatchObject({
|
|
ordering: "NEXT",
|
|
transport: "BROADCAST",
|
|
event: {
|
|
protocolVersion: 1,
|
|
sequence: 1,
|
|
cacheEpoch: CACHE_EPOCH,
|
|
topic: TOPIC,
|
|
topicVersion: 1,
|
|
},
|
|
});
|
|
expect(network.messages).toHaveLength(1);
|
|
expect(JSON.stringify(network.messages[0])).not.toMatch(
|
|
/payload|queryKey|sensitive/,
|
|
);
|
|
|
|
first.close();
|
|
second.close();
|
|
});
|
|
|
|
it("de-duplicates the same event delivered by both transports", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
const storage = new FakeStorageBus().createEndpoint();
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: network.createChannel,
|
|
storage: storage.storage,
|
|
storageEvents: storage.target,
|
|
}),
|
|
);
|
|
const received = vi.fn();
|
|
runtime.subscribe(received);
|
|
const event = wireEvent();
|
|
|
|
network.emit(event);
|
|
storage.target.emit(STORAGE_PULSE_KEY, JSON.stringify(event));
|
|
|
|
expect(received).toHaveBeenCalledOnce();
|
|
expect(received.mock.calls[0]?.[0]).toMatchObject({
|
|
transport: "BROADCAST",
|
|
ordering: "NEXT",
|
|
});
|
|
runtime.close();
|
|
});
|
|
|
|
it("reports per-source gaps and drops stale out-of-order events", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
const observations: CrossContextInvalidationObservation[] = [];
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: network.createChannel,
|
|
observe: (observation) => observations.push(observation),
|
|
}),
|
|
);
|
|
const delivered: CrossContextInvalidationDelivery[] = [];
|
|
runtime.subscribe((delivery) => delivered.push(delivery));
|
|
|
|
network.emit(wireEvent({ eventId: "remote-event-00000001", sequence: 1 }));
|
|
network.emit(wireEvent({ eventId: "remote-event-00000003", sequence: 3 }));
|
|
network.emit(wireEvent({ eventId: "remote-event-00000002", sequence: 2 }));
|
|
network.emit(
|
|
wireEvent({
|
|
eventId: "remote-new-epoch-event",
|
|
sourceEpoch: "remote-epoch-0000002",
|
|
sequence: 1,
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
delivered.map(({ event, ordering }) => [
|
|
event.sequence,
|
|
ordering,
|
|
]),
|
|
).toEqual([
|
|
[1, "NEXT"],
|
|
[3, "GAP"],
|
|
[1, "NEXT"],
|
|
]);
|
|
expect(observations).toContainEqual({
|
|
operation: "RECEIVE",
|
|
outcome: "DROPPED",
|
|
transport: "BROADCAST",
|
|
reason: "STALE",
|
|
});
|
|
runtime.close();
|
|
});
|
|
|
|
it("falls back when BroadcastChannel open or publish fails", () => {
|
|
const storageBus = new FakeStorageBus();
|
|
const firstStorage = storageBus.createEndpoint();
|
|
const secondStorage = storageBus.createEndpoint();
|
|
const firstNetwork = new FakeBroadcastNetwork();
|
|
const secondNetwork = new FakeBroadcastNetwork();
|
|
|
|
const first = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-a", {
|
|
createBroadcastChannel: firstNetwork.createChannel,
|
|
storage: firstStorage.storage,
|
|
storageEvents: firstStorage.target,
|
|
}),
|
|
);
|
|
const second = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: () => {
|
|
throw new DOMException("Denied", "SecurityError");
|
|
},
|
|
storage: secondStorage.storage,
|
|
storageEvents: secondStorage.target,
|
|
}),
|
|
);
|
|
firstNetwork.channels[0]!.failPost = true;
|
|
const received = vi.fn();
|
|
second.subscribe(received);
|
|
|
|
expect(second.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
|
|
expect(first.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
|
ok: true,
|
|
transport: "STORAGE",
|
|
});
|
|
expect(first.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
|
|
expect(received).toHaveBeenCalledOnce();
|
|
expect(firstStorage.storage.setCount).toBe(1);
|
|
expect(firstStorage.storage.removeCount).toBe(1);
|
|
expect(storageBus.values.has(STORAGE_PULSE_KEY)).toBe(false);
|
|
expect(secondNetwork.channels).toHaveLength(0);
|
|
|
|
first.close();
|
|
second.close();
|
|
});
|
|
|
|
it("enters explicit local-only degradation when every transport fails", () => {
|
|
const storageBus = new FakeStorageBus();
|
|
const endpoint = storageBus.createEndpoint();
|
|
endpoint.storage.failSet = true;
|
|
const observations: CrossContextInvalidationObservation[] = [];
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-a", {
|
|
createBroadcastChannel: () => {
|
|
throw new DOMException("Denied", "SecurityError");
|
|
},
|
|
storage: endpoint.storage,
|
|
storageEvents: endpoint.target,
|
|
observe: (observation) => observations.push(observation),
|
|
}),
|
|
);
|
|
|
|
expect(runtime.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
|
|
expect(runtime.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
|
ok: false,
|
|
reason: "TRANSPORT_UNAVAILABLE",
|
|
});
|
|
expect(runtime.getStatus()).toBe("DEGRADED_LOCAL_ONLY");
|
|
expect(observations).toContainEqual({
|
|
operation: "PUBLISH",
|
|
outcome: "DEGRADED",
|
|
transport: "STORAGE",
|
|
reason: "STORAGE_PUBLISH_FAILED",
|
|
});
|
|
runtime.close();
|
|
});
|
|
|
|
it("isolates handler and diagnostics failures without leaking identifiers", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
const observations: CrossContextInvalidationObservation[] = [];
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: network.createChannel,
|
|
observe(observation) {
|
|
observations.push(observation);
|
|
if (observation.reason === "HANDLER_FAILED") {
|
|
throw new Error("diagnostics unavailable");
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
const healthy = vi.fn();
|
|
runtime.subscribe(() => {
|
|
throw new Error("listener secret");
|
|
});
|
|
runtime.subscribe(healthy);
|
|
|
|
network.emit(
|
|
wireEvent({
|
|
eventId: "sensitive-event-identifier",
|
|
topic: TOPIC,
|
|
}),
|
|
);
|
|
|
|
expect(healthy).toHaveBeenCalledOnce();
|
|
expect(observations.some(({ reason }) => reason === "HANDLER_FAILED")).toBe(
|
|
true,
|
|
);
|
|
expect(JSON.stringify(observations)).not.toMatch(
|
|
/sensitive-event-identifier|sample-topic-alpha|cache-epoch-0001/,
|
|
);
|
|
runtime.close();
|
|
});
|
|
|
|
it("bounds TTL and LRU tracking instead of growing with remote sources", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
let currentTime = NOW;
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: network.createChannel,
|
|
nowEpochMilliseconds: () => currentTime,
|
|
dedupeCapacity: 2,
|
|
sourceCapacity: 2,
|
|
}),
|
|
);
|
|
const received = vi.fn();
|
|
runtime.subscribe(received);
|
|
const distinctSource = (
|
|
index: number,
|
|
eventId: string,
|
|
emittedAt = currentTime,
|
|
) =>
|
|
wireEvent({
|
|
eventId,
|
|
sourceId: `remote-source-${index}`,
|
|
sourceEpoch: `remote-epoch-${index}`,
|
|
emittedAt,
|
|
expiresAt: emittedAt + 1_000,
|
|
});
|
|
|
|
network.emit(distinctSource(1, "bounded-event-1"));
|
|
network.emit(distinctSource(2, "bounded-event-2"));
|
|
network.emit(distinctSource(3, "bounded-event-3"));
|
|
network.emit(distinctSource(1, "bounded-event-1"));
|
|
expect(received).toHaveBeenCalledTimes(4);
|
|
|
|
currentTime += 1_001;
|
|
network.emit(
|
|
distinctSource(3, "bounded-event-3", currentTime),
|
|
);
|
|
expect(received).toHaveBeenCalledTimes(5);
|
|
runtime.close();
|
|
});
|
|
|
|
it("cleans up listeners once and rejects queued work after close", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
const storage = new FakeStorageBus().createEndpoint();
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-b", {
|
|
createBroadcastChannel: network.createChannel,
|
|
storage: storage.storage,
|
|
storageEvents: storage.target,
|
|
}),
|
|
);
|
|
const received = vi.fn();
|
|
const unsubscribe = runtime.subscribe(received);
|
|
unsubscribe();
|
|
unsubscribe();
|
|
runtime.subscribe(received);
|
|
|
|
runtime.close();
|
|
runtime.close();
|
|
network.emit(wireEvent());
|
|
storage.target.emit(
|
|
STORAGE_PULSE_KEY,
|
|
JSON.stringify(wireEvent()),
|
|
);
|
|
|
|
expect(runtime.getStatus()).toBe("CLOSED");
|
|
expect(runtime.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
|
ok: false,
|
|
reason: "CLOSED",
|
|
});
|
|
expect(received).not.toHaveBeenCalled();
|
|
expect(network.channels[0]?.closeCount).toBe(1);
|
|
expect(network.channels[0]?.removeCount).toBe(1);
|
|
expect(storage.target.removeCount).toBe(1);
|
|
expect(storage.target.listeners.size).toBe(0);
|
|
});
|
|
|
|
it("fails invalid publish inputs closed without touching a provider", () => {
|
|
const network = new FakeBroadcastNetwork();
|
|
const runtime = createBrowserCrossContextInvalidation(
|
|
dependencies("tab-a", {
|
|
createBroadcastChannel: network.createChannel,
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
runtime.publish({ topic: "not-allowlisted", topicVersion: 1 }),
|
|
).toEqual({ ok: false, reason: "INVALID_EVENT" });
|
|
expect(
|
|
runtime.publish({ topic: TOPIC, topicVersion: 2 }),
|
|
).toEqual({ ok: false, reason: "INVALID_EVENT" });
|
|
expect(network.messages).toHaveLength(0);
|
|
runtime.close();
|
|
});
|
|
});
|