Files
oh-my-pi/packages/coding-agent/test/tools/browser-relay-bridge.test.ts
T
can1357 4a946e2cfc fix(coding-agent/tools): serialized tab grouping operations in the relay bridge
- Serialized group and ungroup operations to prevent duplicate tab group creation races.
- Queued and serially drained tab grouping requests in the relay bridge to prevent overlapping RPCs.
- Mirrored tab group titles to session storage and healed duplicate groups during background service worker recovery.
- Renamed run-cancellation utility to run-scope and updated corresponding module and test references.
2026-08-02 18:07:07 +02:00

283 lines
11 KiB
TypeScript

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<Op extends RelayRpcRequest["op"]> = { t: "rpc"; id: number } & Extract<RelayRpcRequest, { op: Op }>;
class FakeExtSocket implements RelaySocket {
readonly messages: RelayToExtMessage[] = [];
readonly #acked = new Set<number>();
send(text: string): void {
this.messages.push(JSON.parse(text) as RelayToExtMessage);
}
close(): void {}
rpcs<Op extends RelayRpcRequest["op"]>(op: Op): Array<ExtRpc<Op>> {
return this.messages.filter((msg): msg is ExtRpc<Op> => msg.t === "rpc" && msg.op === op);
}
/** RPC requests of `op` not yet answered through {@link ack}. */
pending<Op extends RelayRpcRequest["op"]>(op: Op): Array<ExtRpc<Op>> {
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<Record<string, unknown>> = [];
send(text: string): void {
this.messages.push(JSON.parse(text) as Record<string, unknown>);
}
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<TabSnapshot> & { 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<void> {
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<string> {
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<void> {
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]);
});
});