feat(console-web): synchronize Host state across tabs
- separate global Host state from tab-local shell and Page state - replicate control-plane snapshots through a versioned BroadcastChannel - handshake new tabs without publishing empty startup state - reject self, stale, and malformed cross-tab messages - suppress broadcast echoes while applying remote snapshots - publish API snapshots and connection state atomically - keep Page state private and update only mounted iframe documents - test initial synchronization and protocol rejection paths
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { HostTabSync } from "../src/host/tab-sync.ts";
|
||||
|
||||
class FakeBus {
|
||||
channels = new Set();
|
||||
messages = [];
|
||||
|
||||
createChannel() {
|
||||
const channel = new FakeChannel(this);
|
||||
this.channels.add(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
deliver(sender, message) {
|
||||
const copy = structuredClone(message);
|
||||
this.messages.push(copy);
|
||||
for (const channel of this.channels) {
|
||||
if (channel !== sender) channel.receive(copy);
|
||||
}
|
||||
}
|
||||
|
||||
replay(message) {
|
||||
for (const channel of this.channels) channel.receive(structuredClone(message));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeChannel {
|
||||
listeners = new Set();
|
||||
|
||||
constructor(bus) {
|
||||
this.bus = bus;
|
||||
}
|
||||
|
||||
postMessage(message) {
|
||||
this.bus.deliver(this, message);
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
receive(data) {
|
||||
for (const listener of this.listeners) listener({ data });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.bus.channels.delete(this);
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
test("new tabs request and receive the current Host state", () => {
|
||||
const bus = new FakeBus();
|
||||
const firstState = hostState("http://runtime-a.test");
|
||||
const received = [];
|
||||
const first = new HostTabSync({
|
||||
tabId: "tab-a",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => firstState,
|
||||
applyState: () => {},
|
||||
});
|
||||
const second = new HostTabSync({
|
||||
tabId: "tab-b",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => hostState("http://runtime-b.test"),
|
||||
applyState: (state) => received.push(state),
|
||||
});
|
||||
|
||||
assert.deepEqual(received, [firstState]);
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
test("publishes Host state but rejects self, stale and malformed messages", () => {
|
||||
const bus = new FakeBus();
|
||||
const received = [];
|
||||
const first = new HostTabSync({
|
||||
tabId: "tab-a",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => hostState(""),
|
||||
applyState: (state) => received.push(state),
|
||||
});
|
||||
const second = new HostTabSync({
|
||||
tabId: "tab-b",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => hostState(""),
|
||||
applyState: () => {},
|
||||
});
|
||||
received.length = 0;
|
||||
|
||||
const update = hostState("http://shared.test");
|
||||
second.publish(update);
|
||||
assert.deepEqual(received, [update]);
|
||||
|
||||
const stateMessage = bus.messages.findLast(
|
||||
(message) => message.type === "state" && message.sender === "tab-b",
|
||||
);
|
||||
bus.replay(stateMessage);
|
||||
bus.replay({
|
||||
...stateMessage,
|
||||
sequence: stateMessage.sequence + 1,
|
||||
state: { ...stateMessage.state, connection: "invalid" },
|
||||
});
|
||||
assert.deepEqual(received, [update]);
|
||||
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
function hostState(apiBase) {
|
||||
return {
|
||||
snapshot: null,
|
||||
connection: "online",
|
||||
apiBase,
|
||||
runtimeAction: null,
|
||||
serviceAction: null,
|
||||
deploymentAction: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user