4a946e2cfc
- 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.
283 lines
11 KiB
TypeScript
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]);
|
|
});
|
|
});
|