import { describe, expect, it } from "bun:test"; import { RelayBridge, type RelaySocket } from "@oh-my-pi/pi-coding-agent/tools/browser/relay/bridge"; import type { RelayRpcRequest, RelayToExtMessage, TabSnapshot, } from "@oh-my-pi/pi-coding-agent/tools/browser/relay/protocol"; /** A relay→extension RPC narrowed to one op, tabIds/title/etc. included. */ type ExtRpc = { t: "rpc"; id: number } & Extract; class FakeExtSocket implements RelaySocket { readonly messages: RelayToExtMessage[] = []; readonly #acked = new Set(); send(text: string): void { this.messages.push(JSON.parse(text) as RelayToExtMessage); } close(): void {} rpcs(op: Op): Array> { return this.messages.filter((msg): msg is ExtRpc => msg.t === "rpc" && msg.op === op); } /** RPC requests of `op` not yet answered through {@link ack}. */ pending(op: Op): Array> { return this.rpcs(op).filter(msg => !this.#acked.has(msg.id)); } markAcked(id: number): void { this.#acked.add(id); } } /** Downstream puppeteer-side socket capturing bridge emissions. */ class FakeCdpSocket implements RelaySocket { readonly messages: Array> = []; send(text: string): void { this.messages.push(JSON.parse(text) as Record); } close(): void {} sessionFor(commandId: number): string | undefined { const msg = this.messages.find(m => m.id === commandId); const result = msg && "result" in msg && msg.result && typeof msg.result === "object" ? msg.result : undefined; return result && "sessionId" in result && typeof result.sessionId === "string" ? result.sessionId : undefined; } } function tab(overrides: Partial & { tabId: number }): TabSnapshot { return { url: "https://example.com/", title: "Example", active: false, windowId: 1, pinned: false, groupId: -1, ...overrides, }; } function connect(bridge: RelayBridge, socket: FakeExtSocket, tabs: TabSnapshot[]): void { bridge.extConnected(socket); bridge.extMessage( socket, JSON.stringify({ t: "hello", userAgent: "test", browserVersion: "Chrome/151.0.0.0", tabs, attachedTabIds: [], }), ); } /** Answer every unanswered extension RPC of `op` with `ok: true` and `result`. */ function ack(bridge: RelayBridge, socket: FakeExtSocket, op: RelayRpcRequest["op"], result: unknown = {}): void { for (const rpc of socket.pending(op)) { socket.markAcked(rpc.id); bridge.extMessage(socket, JSON.stringify({ t: "rpcResult", id: rpc.id, ok: true, result })); } } /** Flush the rpc .then() microtask chains (no timers involved). */ async function flush(): Promise { for (let i = 0; i < 5; i++) await Promise.resolve(); } let msgSeq = 100; /** Attach to a tab's page target and return the minted page session id. */ async function attachPage( bridge: RelayBridge, ext: FakeExtSocket, cdp: FakeCdpSocket, connId: number, tabId: number, ): Promise { const attachId = ++msgSeq; bridge.cdpMessage( connId, JSON.stringify({ id: attachId, method: "Target.attachToTarget", params: { targetId: `PAGE${tabId}`, flatten: true }, }), ); ack(bridge, ext, "attach"); await flush(); const sessionId = cdp.sessionFor(attachId); if (!sessionId) throw new Error(`attachToTarget for tab ${tabId} did not produce a session`); return sessionId; } /** * Emulate the omp tab worker adopting a tab: attach to its page target, then * claim it as this connection's drive target. */ async function claimTab( bridge: RelayBridge, ext: FakeExtSocket, cdp: FakeCdpSocket, connId: number, tabId: number, ): Promise { const sessionId = await attachPage(bridge, ext, cdp, connId, tabId); bridge.cdpMessage(connId, JSON.stringify({ id: ++msgSeq, sessionId, method: "OMP.claimTarget" })); await flush(); } describe("RelayBridge tab grouping", () => { it("groups nothing on hello or tab lifecycle events — only claimed tabs join the omp group", () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const socket = new FakeExtSocket(); connect(bridge, socket, [tab({ tabId: 1 }), tab({ tabId: 2 }), tab({ tabId: 3, url: "about:blank" })]); bridge.extMessage(socket, JSON.stringify({ t: "tabCreated", tab: tab({ tabId: 9 }) })); expect(socket.rpcs("group")).toHaveLength(0); }); it("never groups from command traffic: a discovery scan sending page commands to every tab is not driving", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 }), tab({ tabId: 2 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); // pickElectronTarget materializes every discovered page, which makes // puppeteer send Page.enable/Page.getFrameTree to all of them. for (const tabId of [1, 2]) { const sessionId = await attachPage(bridge, ext, cdp, connId, tabId); bridge.cdpMessage(connId, JSON.stringify({ id: ++msgSeq, sessionId, method: "Page.enable" })); bridge.cdpMessage(connId, JSON.stringify({ id: ++msgSeq, sessionId, method: "Page.getFrameTree" })); } await flush(); expect(ext.rpcs("group")).toHaveLength(0); }); it("groups exactly the tab a client claims", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 }), tab({ tabId: 2 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); await claimTab(bridge, ext, cdp, connId, 1); const groups = ext.rpcs("group"); expect(groups).toHaveLength(1); expect(groups[0]!.tabIds).toEqual([1]); expect(groups[0]!.title).toBe("omp"); expect(groups[0]!.color).toBe("cyan"); }); it("never groups pinned tabs or tabs in a user group, even when claimed", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 3, pinned: true }), tab({ tabId: 4, groupId: 77 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); await claimTab(bridge, ext, cdp, connId, 3); await claimTab(bridge, ext, cdp, connId, 4); expect(ext.rpcs("group")).toHaveLength(0); }); it("does not issue group RPCs when grouping is disabled", async () => { const bridge = new RelayBridge({}); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); await claimTab(bridge, ext, cdp, connId, 1); expect(ext.rpcs("group")).toHaveLength(0); }); it("auto-claims a tab created through Target.createTarget", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, []); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); bridge.cdpMessage( connId, JSON.stringify({ id: ++msgSeq, method: "Target.createTarget", params: { url: "https://example.com/" } }), ); ack(bridge, ext, "createTab", { tab: tab({ tabId: 9 }) }); await flush(); const groups = ext.rpcs("group"); expect(groups).toHaveLength(1); expect(groups[0]!.tabIds).toEqual([9]); }); it("never re-groups a tab the user pulled out of the omp group", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); await claimTab(bridge, ext, cdp, connId, 1); ack(bridge, ext, "group", { grouped: { "1": 42 } }); await flush(); // Chrome reports the grouping we just made — no opt-out. bridge.extMessage(ext, JSON.stringify({ t: "tabUpdated", tab: tab({ tabId: 1, groupId: 42 }) })); // The user drags the tab out of the group. bridge.extMessage(ext, JSON.stringify({ t: "tabUpdated", tab: tab({ tabId: 1, groupId: -1 }) })); // A later navigation on the still-claimed tab must not re-group it. bridge.extMessage( ext, JSON.stringify({ t: "tabUpdated", tab: tab({ tabId: 1, groupId: -1, url: "https://example.com/other" }) }), ); expect(ext.rpcs("group")).toHaveLength(1); }); it("ungroups when the claiming client disconnects, even while another connection still holds sessions", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 })]); // Long-lived registry connection: holds a session on the tab, never claims it. const registry = new FakeCdpSocket(); const registryConn = bridge.cdpConnected(registry); await attachPage(bridge, ext, registry, registryConn, 1); // Worker connection: claims the tab. const worker = new FakeCdpSocket(); const workerConn = bridge.cdpConnected(worker); await claimTab(bridge, ext, worker, workerConn, 1); ack(bridge, ext, "group", { grouped: { "1": 42 } }); await flush(); bridge.cdpClosed(workerConn); const ungroups = ext.rpcs("ungroup"); expect(ungroups).toHaveLength(1); expect(ungroups[0]!.tabIds).toEqual([1]); }); it("never overlaps group RPCs: a tab claimed mid-flight waits for the pending group", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 }), tab({ tabId: 2 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); await claimTab(bridge, ext, cdp, connId, 1); expect(ext.rpcs("group")).toHaveLength(1); // Concurrent group RPCs race Chrome's non-atomic query→create→set-title // and mint duplicate "omp" groups; the second request must queue. await claimTab(bridge, ext, cdp, connId, 2); expect(ext.rpcs("group")).toHaveLength(1); ack(bridge, ext, "group", { grouped: { "1": 42 } }); await flush(); const groups = ext.rpcs("group"); expect(groups).toHaveLength(2); expect(groups[1]!.tabIds).toEqual([2]); }); it("regroups claimed tabs after an extension reconnect instead of treating the dissolve as user opt-out", async () => { const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } }); const ext = new FakeExtSocket(); connect(bridge, ext, [tab({ tabId: 1 })]); const cdp = new FakeCdpSocket(); const connId = bridge.cdpConnected(cdp); await claimTab(bridge, ext, cdp, connId, 1); ack(bridge, ext, "group", { grouped: { "1": 42 } }); await flush(); // Relay/extension link drops: the extension dissolves the omp group on // disconnect, so the next hello reports groupId -1 for every tab. bridge.extClosed(ext); const ext2 = new FakeExtSocket(); connect(bridge, ext2, [tab({ tabId: 1, groupId: -1 })]); const groups = ext2.rpcs("group"); expect(groups).toHaveLength(1); expect(groups[0]!.tabIds).toEqual([1]); }); });