mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
050e8a8ef3
## 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.
83 lines
2.9 KiB
TypeScript
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();
|
|
});
|
|
});
|