Files
software-mansion__argent/packages/tool-server/test/cdp-client-reconnect.test.ts
filip131311 050e8a8ef3 feat(chromium): multi-tab / window support (#334)
## Summary

Adds a **`chromium-tabs`** tool to list / select / open / close the tabs
(or Electron `BrowserWindow`s) of a Chromium (CDP) device. This closes
the biggest gap vs general browser-automation tools — argent previously
drove only the single primary page.

First of three stacked PRs (tabs → network → cookies/storage).

## How it works

- **`CDPClient.reconnect(wsUrl)`** re-points the client at a different
page target *in place* — same object identity, and crucially **without
emitting `disconnected`** (which callers wire to teardown/termination).
So when the active tab changes, every page-scoped subsystem that
captured `cdp` (`describe`, `gesture-*`, `screenshot`,
`debugger-evaluate`, `open-url`, …) follows automatically with **zero
per-tool changes**.
- **`TabsManager`** (`chromium-server/tabs.ts`): stable
`tabId`↔`targetId` map (`t1`, `t2`, … never reused), optional **labels**
usable interchangeably with the id, lists via `/json/list`,
creates/closes via browser-level `Target.createTarget` /
`Target.closeTarget`. Switching reconnects the shared page client,
re-primes core domains, and refreshes the viewport. Closing the active
tab falls back to another live tab.
- **`chromium-tabs` tool**: `action = list | select | new | close` (+
`tab` / `url` / `label`). Chromium-only; iOS/Android rejected by the
capability gate.

## Test plan

- [x] 11 new unit tests: `reconnect` is quiet + re-points + rejects
in-flight requests (real ws servers); `TabsManager` list / select (by id
**and** label) / open / close-with-active-fallback + stable-id
assertions; tool capability gate + action dispatch.
- [x] Full suite **1127 pass**; `tsc --build` + `typecheck:tests` +
prettier clean.
- [x] **Live (Electron)**: testbed lists its single window as `t1`.
- [x] **Live (Chrome for Testing)**: `list` (1) → `new`+activate (2) →
`select` by tabId → `select` by **label** → `close` active with
fallback. Active-follows verified by reading `location.hostname` after
each switch (t1↔t2 distinct origins tracked correctly).

## Known caveat

A cross-process navigation (some redirects, e.g. observed with an
`iana.org` redirect) can swap a tab's underlying CDP target id; re-run
`chromium-tabs action=list` to pick it up under a fresh id. Documented
in the `argent-device-interact` skill. This is a property of the page-WS
model, not introduced here.
2026-06-15 14:32:06 +02:00

83 lines
2.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { WebSocketServer } from "ws";
import { CDPClient } from "../src/utils/debugger/cdp-client";
/** Minimal CDP-ish echo server: replies to any request with `{ server: <id> }`. */
function startEchoServer(serverId: number): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve) => {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }, () => {
const addr = wss.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
resolve({
url: `ws://127.0.0.1:${port}/devtools/page/${serverId}`,
close: () => new Promise<void>((r) => wss.close(() => r())),
});
});
wss.on("connection", (sock) => {
sock.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
sock.send(JSON.stringify({ id: msg.id, result: { server: serverId } }));
});
});
});
}
describe("CDPClient.reconnect", () => {
it("re-points to a new target without emitting `disconnected`", async () => {
const s1 = await startEchoServer(1);
const s2 = await startEchoServer(2);
const cdp = new CDPClient(s1.url, { sendOrigin: false });
await cdp.connect();
let disconnects = 0;
cdp.events.on("disconnected", () => {
disconnects++;
});
expect(await cdp.send("Test.ping")).toEqual({ server: 1 });
await cdp.reconnect(s2.url);
// Switched in place: same object, now talking to server 2, still connected,
// and crucially NO disconnected event fired (callers wire that to teardown).
expect(cdp.isConnected()).toBe(true);
expect(await cdp.send("Test.ping")).toEqual({ server: 2 });
expect(disconnects).toBe(0);
await cdp.disconnect();
await s1.close();
await s2.close();
});
it("rejects in-flight requests on the old socket when reconnecting", async () => {
// A server that never replies, so the request is still pending at reconnect.
const silent = await new Promise<{ url: string; close: () => Promise<void> }>((resolve) => {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }, () => {
const addr = wss.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
resolve({
url: `ws://127.0.0.1:${port}/devtools/page/silent`,
close: () => new Promise<void>((r) => wss.close(() => r())),
});
});
});
const s2 = await startEchoServer(9);
const cdp = new CDPClient(silent.url, { sendOrigin: false });
await cdp.connect();
const pending = cdp.send("Test.neverReplies", {}, 5000);
const settled = pending.then(
() => "resolved",
(e) => `rejected:${(e as Error).message}`
);
await cdp.reconnect(s2.url);
await expect(settled).resolves.toContain("rejected");
expect(await cdp.send("Test.ping")).toEqual({ server: 9 });
await cdp.disconnect();
await silent.close();
await s2.close();
});
});