aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-06-21 00:46:44 +0530
committerGitHub <noreply@github.com>2026-06-21 00:46:44 +0530
commitda5d31af999b728c31ab0169373531cab4543f0c (patch)
tree5d350ad1428ff332372a9c724ac46c964d8ca477 /test
parent8db684d43366d335760e106eae39a74376bd7640 (diff)
release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init (#6)
* feat: Unix socket daemon with auto-reconnect for persistent Chrome CDP Eliminates the 'Allow Remote Debugging' Chrome prompt that appeared on every pi session by multiplexing pi instances through a single persistent daemon process. Key additions: - Unix socket IPC (net.createServer + readline JSON framing) - CdpBridge with ID multiplexer + event router - DaemonTransport implementing CdpTransport interface - Auto-spawn with ensureDaemon() (lazy, checks socket) Fixes since initial implementation: - Bridge auto-reconnects to Chrome with exponential backoff (1s-60s). Previously the bridge tried once at startup and permanently failed if Chrome wasn't ready — causing 'Chrome not connected' on every browser tool call. - Daemon spawn uses project-local tsx binary instead of global --import flag, fixing silent spawn failure (ERR_MODULE_NOT_FOUND). - Stale ctx crash: persistState in onOwnershipChange wrapped in try/catch to prevent CDP event consumer crashes. 49 test assertions across 4 test files. Zero TypeScript errors. * feat: on-demand browser initialization Browser daemon no longer auto-starts on session_start. Instead: - session_start creates the client lazily but does NOT connect or spawn the daemon. Zero Chrome prompts for non-browser pi sessions. - Browser tool wrapper checks for daemon socket before each call. Socket missing → clear error: 'Run /browser-setup first'. Socket exists → proceeds silently (daemon already running). - /browser-setup is the explicit, one-time init point. It spawns the daemon and connects to Chrome only when the user opts in. - SKILL.md updated with Connection Setup instructions telling agents to direct users to /browser-setup when socket is missing. * fix: checkChromeRunning on macOS — match by substring not exact ps -A -o comm= returns full executable paths on modern macOS (e.g. /Applications/Google Chrome.app/Contents/MacOS/Google Chrome), not the basename. The old exact-match check (browserProcesses.includes(l)) never matched, causing false 'No browser instance running' errors. Changed to substring includes() with helper/renderer/crashpad filtering. * feat: browser_setup as agent-callable tool The agent can now self-recover from 'Browser harness not initialized' errors by calling browser_setup directly — no user mediation needed. - Extracted shared performSetup() from the slash command into setup.ts - Created src/domains/setup.ts with ensureAlive:false to bypass the socket guard (this tool IS the setup, can't require itself) - Registered browser_setup in the tool registry - Updated SKILL.md: agent calls browser_setup directly, not ask user - Removed stale test/manual/spawn-test.ts * release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init
Diffstat (limited to 'test')
-rw-r--r--test/manual/bridge-test.ts182
-rw-r--r--test/manual/daemon-transport-test.ts117
-rw-r--r--test/manual/e2e-test.ts163
-rw-r--r--test/manual/ipc-server-test.ts141
-rw-r--r--test/manual/live-daemon-test.ts21
-rw-r--r--test/manual/socket-guard-test.ts45
6 files changed, 669 insertions, 0 deletions
diff --git a/test/manual/bridge-test.ts b/test/manual/bridge-test.ts
new file mode 100644
index 0000000..ecb4100
--- /dev/null
+++ b/test/manual/bridge-test.ts
@@ -0,0 +1,182 @@
+/**
+ * Manual integration test for CdpBridge against real Chrome.
+ * Requires: Chrome with remote debugging enabled (chrome://inspect/#remote-debugging).
+ * Run: npx tsx test/manual/bridge-test.ts
+ */
+import { createCdpBridge, type SendToClient, type CdpBridge } from "../../src/daemon/bridge";
+import type { WireRequest, WireResponse, WireEvent } from "../../src/daemon/protocol";
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string) => {
+ if (cond) { passed++; console.log(` ✓ ${label}`); }
+ else { failed++; console.error(` ✗ ${label}`); }
+};
+
+async function main() {
+ console.log("Starting CdpBridge...");
+ const bridge = createCdpBridge();
+
+ // ── Test 1: Connect to Chrome ──────────────────────────────────────────
+ // start() now runs auto-retry in the background. Wait a moment for
+ // the initial connection attempt to complete.
+ await bridge.start();
+ await sleep(3000); // allow time for WebSocket handshake
+ if (!bridge.isAlive()) {
+ console.error("Cannot continue — Chrome not reachable. Run /browser-setup first.");
+ console.error("(The bridge will keep retrying in the background.)");
+ process.exit(1);
+ }
+ console.log(" ✓ Connect to Chrome: ok");
+ check(bridge.isAlive(), "Bridge reports alive after connect");
+
+ // ── Test 2: Send Target.getTargets ─────────────────────────────────────
+ const responses: WireResponse[] = [];
+ const events: WireEvent[] = [];
+ let eventCount = 0;
+
+ const send: SendToClient = (clientId, msg) => {
+ if (msg.type === "response") responses.push(msg);
+ else events.push(msg);
+ };
+
+ bridge.onEvent((evt, _targets) => { eventCount++; });
+
+ const req1: WireRequest = { type: "request", id: 1, method: "Target.getTargets" };
+ bridge.handleRequest(req1, "pi-test", send);
+
+ await sleep(2000);
+
+ const res1 = responses.find(r => r.id === 1);
+ check(res1 != null, "Target.getTargets response received");
+ if (res1?.result) {
+ const r = res1.result as any;
+ check(Array.isArray(r.targetInfos), "targetInfos is array");
+ console.log(` Found ${r.targetInfos?.length ?? 0} targets`);
+ }
+
+ // ── Test 3: Create a new tab ───────────────────────────────────────────
+ responses.length = 0;
+ const req2: WireRequest = {
+ type: "request", id: 2, method: "Target.createTarget",
+ params: { url: "about:blank", newWindow: true },
+ };
+ bridge.handleRequest(req2, "pi-test", send);
+ await sleep(2000);
+
+ const res2 = responses.find(r => r.id === 2);
+ check(res2 != null, "Target.createTarget response received");
+ const targetId = (res2?.result as any)?.targetId as string | undefined;
+ check(typeof targetId === "string", `Created target: ${targetId ?? "FAILED"}`);
+
+ if (!targetId) {
+ console.error("Cannot continue without a target");
+ await bridge.stop();
+ process.exit(1);
+ }
+
+ // ── Test 4: Attach to target ───────────────────────────────────────────
+ responses.length = 0;
+ const req3: WireRequest = {
+ type: "request", id: 3, method: "Target.attachToTarget",
+ params: { targetId, flatten: true },
+ };
+ bridge.handleRequest(req3, "pi-test", send);
+ await sleep(2000);
+
+ const res3 = responses.find(r => r.id === 3);
+ check(res3 != null, "Target.attachToTarget response received");
+ const sessionId = (res3?.result as any)?.sessionId as string | undefined;
+ check(typeof sessionId === "string", `Session ID: ${sessionId ?? "FAILED"}`);
+
+ // Verify session ownership was tracked
+ if (sessionId) {
+ const owner = bridge.getSessionOwner(sessionId);
+ check(owner === "pi-test", "Session ownership tracked correctly");
+ }
+
+ if (!sessionId) {
+ await bridge.stop();
+ process.exit(1);
+ }
+
+ // ── Test 5: Navigate tab via session ───────────────────────────────────
+ responses.length = 0;
+ const req4: WireRequest = {
+ type: "request", id: 4, method: "Page.navigate",
+ params: { url: "https://example.com" },
+ sessionId,
+ };
+ bridge.handleRequest(req4, "pi-test", send);
+ await sleep(3000);
+
+ const res4 = responses.find(r => r.id === 4);
+ check(res4 != null, "Page.navigate response received");
+ const frameId = (res4?.result as any)?.frameId as string | undefined;
+ check(typeof frameId === "string", `Navigated to example.com (frame: ${frameId ?? "FAILED"})`);
+
+ // ── Test 6: Evaluate JS in the page ────────────────────────────────────
+ responses.length = 0;
+ const req5: WireRequest = {
+ type: "request", id: 5, method: "Runtime.evaluate",
+ params: { expression: "document.title", returnByValue: true },
+ sessionId,
+ };
+ bridge.handleRequest(req5, "pi-test", send);
+ await sleep(2000);
+
+ const res5 = responses.find(r => r.id === 5);
+ check(res5 != null, "Runtime.evaluate response received");
+ const title = (res5?.result as any)?.result?.value as string | undefined;
+ check(title === "Example Domain", `Page title: "${title ?? "FAILED"}" (expected "Example Domain")`);
+
+ // ── Test 7: Detach from target ─────────────────────────────────────────
+ responses.length = 0;
+ const req6: WireRequest = {
+ type: "request", id: 6, method: "Target.detachFromTarget",
+ params: { sessionId },
+ };
+ bridge.handleRequest(req6, "pi-test", send);
+ await sleep(1000);
+
+ const res6 = responses.find(r => r.id === 6);
+ check(res6 != null, "Target.detachFromTarget response received");
+
+ // Session ownership should be released
+ const ownerAfter = bridge.getSessionOwner(sessionId);
+ check(ownerAfter === undefined, "Session ownership released after detach");
+
+ // ── Test 8: Close target ───────────────────────────────────────────────
+ responses.length = 0;
+ const req7: WireRequest = {
+ type: "request", id: 7, method: "Target.closeTarget",
+ params: { targetId },
+ };
+ bridge.handleRequest(req7, "pi-test", send);
+ await sleep(1000);
+
+ const res7 = responses.find(r => r.id === 7);
+ check(res7 != null, "Target.closeTarget response received");
+
+ // ── Test 9: Remove client ──────────────────────────────────────────────
+ bridge.removeClient("pi-test");
+ // After removal, no pending requests should remain — verify by checking
+ // no errors are thrown when handling subsequent events.
+
+ // ── Cleanup ────────────────────────────────────────────────────────────
+ console.log("\nStopping bridge...");
+ await bridge.stop();
+ console.log("Bridge stopped ✓");
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+function sleep(ms: number): Promise<void> {
+ return new Promise(r => setTimeout(r, ms));
+}
+
+main().catch((e) => {
+ console.error("Test failed:", e);
+ process.exit(1);
+});
diff --git a/test/manual/daemon-transport-test.ts b/test/manual/daemon-transport-test.ts
new file mode 100644
index 0000000..4ba2f5b
--- /dev/null
+++ b/test/manual/daemon-transport-test.ts
@@ -0,0 +1,117 @@
+/**
+ * Integration test: IpcServer + DaemonTransport end-to-end.
+ * Starts a real IpcServer, connects a DaemonTransport, sends CDP requests.
+ * No Chrome required — the server simulates CDP responses.
+ *
+ * Run: npx tsx test/manual/daemon-transport-test.ts
+ */
+import { createIpcServer } from "../../src/daemon/server";
+import { createDaemonTransport } from "../../src/cdp/daemon-transport";
+import { DAEMON_SOCKET_PATH, serialize } from "../../src/daemon/protocol";
+import type { WireMessage } from "../../src/daemon/protocol";
+import { unlinkSync } from "node:fs";
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string) => {
+ if (cond) { passed++; console.log(` ✓ ${label}`); }
+ else { failed++; console.error(` ✗ ${label}`); }
+};
+
+async function main() {
+ // Clean up any stale socket
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
+
+ // ── Start IpcServer ────────────────────────────────────────────────────
+ console.log("Starting IpcServer...");
+ const server = createIpcServer();
+
+ // When a request arrives, reply with a fake CDP response
+ server.onMessage((msg, client) => {
+ if (msg.type === "request") {
+ // Simulate a CDP response
+ const reply: WireMessage = {
+ type: "response",
+ id: msg.id,
+ result: { fakeResult: true, method: msg.method },
+ };
+ server.send(client.id, reply);
+ }
+ });
+
+ await server.start();
+ console.log("Server started ✓");
+
+ // ── Create DaemonTransport ─────────────────────────────────────────────
+ const transport = createDaemonTransport("pi-test-transport");
+
+ // ── Test 1: Connect ────────────────────────────────────────────────────
+ const connResult = await transport.connect("");
+ check(connResult.success, `Connect: ${connResult.success ? "ok" : connResult.error.message}`);
+ check(transport.state() === "open", `State is "open" (actual: "${transport.state()}")`);
+
+ // ── Test 2: Send a request ─────────────────────────────────────────────
+ const reqResult = await transport.request("Page.navigate", { url: "https://example.com" });
+ check(reqResult.success, `Request succeeded: ${reqResult.success}`);
+ if (reqResult.success) {
+ const data = reqResult.data as any;
+ check(data?.fakeResult === true, "Response has fakeResult: true");
+ check(data?.method === "Page.navigate", "Response echoes method");
+ }
+
+ // ── Test 3: Send request with sessionId ────────────────────────────────
+ const req2 = await transport.request("Runtime.evaluate", { expression: "1+1" }, { sessionId: "abc123" });
+ check(req2.success, "Request with sessionId succeeded");
+
+ // ── Test 4: Events ─────────────────────────────────────────────────────
+ const eventPromise = new Promise<boolean>((resolve) => {
+ const timeout = setTimeout(() => resolve(false), 2000);
+ (async () => {
+ for await (const evt of transport.events()) {
+ clearTimeout(timeout);
+ resolve(evt.method === "Target.targetCreated");
+ break;
+ }
+ })();
+ });
+
+ // Simulate server sending an event
+ server.broadcast({
+ type: "event",
+ method: "Target.targetCreated",
+ params: { targetInfo: { type: "page", targetId: "test-123" } },
+ });
+
+ const gotEvent = await eventPromise;
+ check(gotEvent, "Event received via transport.events()");
+
+ // ── Test 5: Close transport ────────────────────────────────────────────
+ let closeFired = false;
+ transport.onClose(() => { closeFired = true; });
+
+ await transport.close();
+ check(transport.state() === "closed", "State is closed after close()");
+ check(closeFired, "onClose handler fired");
+
+ // ── Test 6: Reconnect ──────────────────────────────────────────────────
+ await new Promise(r => setTimeout(r, 500)); // let server process socket close
+ const reconnectResult = await transport.connect("");
+ check(reconnectResult.success, `Reconnect: ${reconnectResult.success ? "ok" : reconnectResult.error.message}`);
+
+ // ── Test 7: Request after reconnect ────────────────────────────────────
+ const req3 = await transport.request("Browser.getVersion", {});
+ check(req3.success, "Request after reconnect succeeded");
+
+ // ── Cleanup ────────────────────────────────────────────────────────────
+ await transport.close();
+ await server.stop();
+ console.log("Cleanup complete ✓");
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+main().catch((e) => {
+ console.error("Test failed:", e);
+ process.exit(1);
+});
diff --git a/test/manual/e2e-test.ts b/test/manual/e2e-test.ts
new file mode 100644
index 0000000..963efaf
--- /dev/null
+++ b/test/manual/e2e-test.ts
@@ -0,0 +1,163 @@
+/**
+ * End-to-end integration test: full daemon pipeline.
+ *
+ * Starts the daemon (IpcServer + CdpBridge), connects via DaemonTransport,
+ * exercises the full CDP request/response/event lifecycle.
+ *
+ * Run: npx tsx test/manual/e2e-test.ts
+ */
+import { createIpcServer } from "../../src/daemon/server";
+import { createCdpBridge, type SendToClient } from "../../src/daemon/bridge";
+import { createDaemonTransport } from "../../src/cdp/daemon-transport";
+import { DAEMON_SOCKET_PATH } from "../../src/daemon/protocol";
+import type { WireRequest } from "../../src/daemon/protocol";
+import { unlinkSync } from "node:fs";
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string) => {
+ if (cond) { passed++; console.log(` ✓ ${label}`); }
+ else { failed++; console.error(` ✗ ${label}`); }
+};
+
+async function main() {
+ // Clean up stale socket
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
+
+ // ── Start daemon ───────────────────────────────────────────────────────
+ console.log("Starting daemon (IpcServer + CdpBridge)...");
+ const ipcServer = createIpcServer();
+ const cdpBridge = createCdpBridge();
+
+ // Wire IpcServer ↔ CdpBridge
+ ipcServer.onMessage((msg, client) => {
+ if (msg.type !== "request") return;
+ const send: SendToClient = (cid, resp) => ipcServer.send(cid, resp);
+ cdpBridge.handleRequest(msg, client.id, send);
+ });
+
+ cdpBridge.onEvent((event, targetClientIds) => {
+ for (const cid of targetClientIds) ipcServer.send(cid, event);
+ });
+
+ ipcServer.onDisconnect((client) => cdpBridge.removeClient(client.id));
+
+ await ipcServer.start();
+ console.log("Daemon started ✓");
+
+ // The bridge will try to connect to Chrome — might fail, that's ok for
+ // this test. We'll simulate CDP responses from the server side.
+
+ // ── Connect pi client via DaemonTransport ──────────────────────────────
+ console.log("\nConnecting pi client (DaemonTransport)...");
+ const transport = createDaemonTransport("pi-e2e-test");
+
+ const connectResult = await transport.connect("");
+ check(connectResult.success, `Client connect: ${connectResult.success ? "ok" : connectResult.error.message}`);
+ check(transport.state() === "open", "Transport state is open");
+
+ // ── Test 1: Request → simulated CDP response ───────────────────────────
+ // The IpcServer's onMessage handler forwards requests to the bridge.
+ // The bridge tries to send to Chrome (which may be down). For this test,
+ // we intercept at the IpcServer level and simulate CDP responses.
+
+ // Override the message handler to simulate CDP responses directly.
+ let rawRequests: WireRequest[] = [];
+ ipcServer.onMessage((msg, client) => {
+ if (msg.type === "request") {
+ rawRequests.push(msg);
+ // Simulate a CDP success response
+ ipcServer.send(client.id, {
+ type: "response",
+ id: msg.id,
+ result: { simulated: true, method: msg.method },
+ });
+ }
+ });
+
+ const res1 = await transport.request("Page.navigate", { url: "https://example.com" });
+ check(res1.success, "CDP request succeeded");
+ if (res1.success) {
+ const d = res1.data as any;
+ check(d?.simulated === true, "Response contains simulated flag");
+ check(d?.method === "Page.navigate", "Response echoes method");
+ }
+ check(rawRequests.length === 1, "Server received exactly 1 request");
+ check(rawRequests[0]!.method === "Page.navigate", "Correct method forwarded");
+
+ // ── Test 2: Request with sessionId ─────────────────────────────────────
+ rawRequests.length = 0;
+ const res2 = await transport.request("Runtime.evaluate", { expression: "1+1" }, { sessionId: "session-abc" });
+ check(res2.success, "Session-scoped request succeeded");
+ check(rawRequests[0]?.sessionId === "session-abc", "SessionId forwarded correctly");
+
+ // ── Test 3: Multiple parallel requests ─────────────────────────────────
+ rawRequests.length = 0;
+ const [r3a, r3b, r3c] = await Promise.all([
+ transport.request("Target.getTargets", {}),
+ transport.request("Browser.getVersion", {}),
+ transport.request("Page.captureScreenshot", {}),
+ ]);
+ check(r3a.success && r3b.success && r3c.success, "All 3 parallel requests succeeded");
+ check(rawRequests.length === 3, "Server received all 3 requests");
+
+ // ── Test 4: Events ─────────────────────────────────────────────────────
+ const eventPromise = new Promise<string | null>((resolve) => {
+ const timeout = setTimeout(() => resolve(null), 2000);
+ (async () => {
+ for await (const evt of transport.events()) {
+ clearTimeout(timeout);
+ resolve(evt.method);
+ break;
+ }
+ })();
+ });
+
+ // Simulate a CDP event broadcast from the daemon
+ ipcServer.broadcast({
+ type: "event",
+ method: "Target.targetCreated",
+ params: { targetInfo: { type: "page", targetId: "e2e-test-target" } },
+ });
+
+ const eventMethod = await eventPromise;
+ check(eventMethod === "Target.targetCreated", `Event received: ${eventMethod ?? "TIMEOUT"}`);
+
+ // ── Test 5: Session persistence (detach simulation) ────────────────────
+ // Simulate a session shutdown: close the transport, then reconnect.
+ // The transport.close() sends deregister, server cleans up.
+ await transport.close();
+ check(transport.state() === "closed", "Transport closed");
+
+ // Small delay for server cleanup
+ await new Promise(r => setTimeout(r, 500));
+
+ // Reconnect — simulates next pi session
+ const reconnectResult = await transport.connect("");
+ check(reconnectResult.success, "Transport reconnected (simulated session restart)");
+
+ // Send a request through the reconnected transport
+ const res4 = await transport.request("Target.getTargets", {});
+ check(res4.success, "Request after reconnect succeeded");
+ check(transport.state() === "open", "Transport state is open after reconnect");
+
+ // ── Test 6: Server-side client count ───────────────────────────────────
+ check(ipcServer.clientCount() === 1, "Server reports 1 connected client");
+
+ // ── Test 7: Client disconnect cleanup ──────────────────────────────────
+ await transport.close();
+ await new Promise(r => setTimeout(r, 500));
+ check(ipcServer.clientCount() === 0, "Server reports 0 clients after disconnect");
+
+ // ── Cleanup ────────────────────────────────────────────────────────────
+ await ipcServer.stop();
+ console.log("\nDaemon stopped ✓");
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+main().catch((e) => {
+ console.error("E2E test failed:", e);
+ process.exit(1);
+});
diff --git a/test/manual/ipc-server-test.ts b/test/manual/ipc-server-test.ts
new file mode 100644
index 0000000..a267a45
--- /dev/null
+++ b/test/manual/ipc-server-test.ts
@@ -0,0 +1,141 @@
+/**
+ * Manual smoke test for IpcServer.
+ * Starts a server, connects a client, tests registration, send, broadcast, disconnect.
+ * Run: npx tsx test/manual/ipc-server-test.ts
+ */
+import { createConnection } from "node:net";
+import { createInterface } from "node:readline";
+import { createIpcServer } from "../../src/daemon/server";
+import { DAEMON_SOCKET_PATH } from "../../src/daemon/protocol";
+import type { WireMessage } from "../../src/daemon/protocol";
+
+const TEST_CLIENT_ID = "pi-test-001";
+
+async function main() {
+ let passed = 0;
+ let failed = 0;
+ const check = (cond: boolean, label: string) => {
+ if (cond) { passed++; console.log(` ✓ ${label}`); }
+ else { failed++; console.error(` ✗ ${label}`); }
+ };
+
+ // ── Start server ──────────────────────────────────────────────────────
+ console.log("Starting IpcServer...");
+ const server = createIpcServer();
+
+ const receivedByServer: WireMessage[] = [];
+ let connectCount = 0;
+ let disconnectCount = 0;
+
+ server.onMessage((msg) => { receivedByServer.push(msg); });
+ server.onConnect(() => { connectCount++; });
+ server.onDisconnect(() => { disconnectCount++; });
+
+ await server.start();
+ console.log("Server started ✓");
+
+ // ── Connect client ────────────────────────────────────────────────────
+ console.log("\nConnecting client...");
+ const socket = createConnection(DAEMON_SOCKET_PATH);
+ const rl = createInterface({ input: socket, crlfDelay: Infinity });
+
+ const receivedByClient: WireMessage[] = [];
+ rl.on("line", (line) => {
+ try { receivedByClient.push(JSON.parse(line)); } catch { /* ignore */ }
+ });
+
+ await new Promise<void>((resolve) => socket.on("connect", resolve));
+ console.log("Client connected ✓");
+
+ // ── Test 1: Registration ──────────────────────────────────────────────
+ // Send register, expect registered ack.
+ socket.write(JSON.stringify({ type: "control", action: "register", clientId: TEST_CLIENT_ID }) + "\n");
+ await sleep(200);
+
+ const ack = receivedByClient.find((m: any) => m.type === "control" && m.action === "registered");
+ check(ack != null, "Registration acknowledged");
+ check(connectCount === 1, "Connect handler fired");
+
+ // ── Test 2: Send message to server ────────────────────────────────────
+ receivedByServer.length = 0;
+ const testRequest = { type: "request", id: 1, method: "Page.navigate", params: { url: "https://example.com" } };
+ socket.write(JSON.stringify(testRequest) + "\n");
+ await sleep(200);
+
+ const received = receivedByServer.find((m: any) => m.type === "request" && m.id === 1);
+ check(received != null, "Server received request from client");
+ check((received as any)?.method === "Page.navigate", "Method matches");
+
+ // ── Test 3: Server → client send ──────────────────────────────────────
+ receivedByClient.length = 0;
+ server.send(TEST_CLIENT_ID, { type: "response", id: 1, result: { frameId: "xyz" } });
+ await sleep(200);
+
+ const response = receivedByClient.find((m: any) => m.type === "response" && m.id === 1);
+ check(response != null, "Client received response from server");
+
+ // ── Test 4: Broadcast ─────────────────────────────────────────────────
+ receivedByClient.length = 0;
+ const broadcastMsg = { type: "event", method: "Target.targetCreated", params: { targetInfo: { type: "page" } } };
+ server.broadcast(broadcastMsg);
+ await sleep(200);
+
+ check(receivedByClient.length === 1, "Client received broadcast");
+ const bc = receivedByClient[0] as any;
+ check(bc?.method === "Target.targetCreated", "Broadcast content matches");
+
+ // ── Test 5: Client disconnect ─────────────────────────────────────────
+ socket.destroy();
+ await sleep(500);
+
+ check(disconnectCount === 1, "Disconnect handler fired");
+ check(server.clientCount() === 0, "Client count is 0 after disconnect");
+
+ // ── Test 6: Re-registration (same clientId after disconnect) ──────────
+ const socket2 = createConnection(DAEMON_SOCKET_PATH);
+ const rl2 = createInterface({ input: socket2, crlfDelay: Infinity });
+ await new Promise<void>((resolve) => socket2.on("connect", resolve));
+
+ socket2.write(JSON.stringify({ type: "control", action: "register", clientId: TEST_CLIENT_ID }) + "\n");
+ await sleep(200);
+ check(server.clientCount() === 1, "Re-registration works after disconnect");
+ socket2.destroy();
+ socket2.on("error", () => {}); // suppress EPIPE from server-side cleanup
+ await sleep(300);
+
+ // ── Test 7: Max clients enforcement ───────────────────────────────────
+ // Connect max clients, then one more should be rejected.
+ const testSockets: ReturnType<typeof createConnection>[] = [];
+ for (let i = 0; i < 20; i++) {
+ const s = createConnection(DAEMON_SOCKET_PATH);
+ s.on("error", () => {}); // server may reject and destroy this socket
+ testSockets.push(s);
+ await new Promise<void>((resolve) => s.on("connect", resolve));
+ try { s.write(JSON.stringify({ type: "control", action: "register", clientId: `pi-test-max-${i}` }) + "\n"); } catch {}
+ await sleep(20);
+ }
+ await sleep(300);
+ check(server.clientCount() <= 16, "Max client enforcement works");
+ // Clean up test sockets (some already destroyed by server rejection)
+ for (const s of testSockets) {
+ try { s.destroy(); } catch {}
+ s.removeAllListeners();
+ }
+
+ // ── Cleanup ───────────────────────────────────────────────────────────
+ console.log(`\nCleaning up...`);
+ await server.stop();
+ console.log("Server stopped ✓");
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+function sleep(ms: number): Promise<void> {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+main().catch((e) => {
+ console.error("Test failed:", e);
+ process.exit(1);
+});
diff --git a/test/manual/live-daemon-test.ts b/test/manual/live-daemon-test.ts
new file mode 100644
index 0000000..28ec7cd
--- /dev/null
+++ b/test/manual/live-daemon-test.ts
@@ -0,0 +1,21 @@
+import { createDaemonTransport } from "../../src/cdp/daemon-transport";
+
+async function main() {
+ const t = createDaemonTransport("pi-live-test");
+ const r = await t.connect("");
+ console.log("Connect:", r.success ? "ok" : r.error.message);
+ console.log("State:", t.state());
+
+ const res = await t.request("Browser.getVersion", {});
+ if (res.success) {
+ const d = res.data as any;
+ console.log("Browser:", d?.product ?? d);
+ } else {
+ console.log("Error:", res.error.message);
+ }
+
+ await t.close();
+ console.log("Done");
+}
+
+main();
diff --git a/test/manual/socket-guard-test.ts b/test/manual/socket-guard-test.ts
new file mode 100644
index 0000000..ab519f9
--- /dev/null
+++ b/test/manual/socket-guard-test.ts
@@ -0,0 +1,45 @@
+/**
+ * Test: socket guard produces clear error when daemon not running.
+ */
+
+import { access } from "node:fs/promises";
+import { setTimeout as sleep } from "node:timers/promises";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { DAEMON_SOCKET_PATH } from "../../src/daemon/protocol";
+
+async function main() {
+ const sock = DAEMON_SOCKET_PATH;
+
+ // Simulate no daemon
+ try {
+ await access(sock);
+ console.log("FAIL: Socket exists unexpectedly");
+ process.exit(1);
+ } catch {
+ console.log("✓ Socket missing — tool wrapper returns:");
+ console.log(' "Browser harness not initialized. Run /browser-setup first"');
+ }
+
+ // Start daemon
+ const daemonEntry = join(dirname(fileURLToPath(import.meta.url)), "../../src/daemon/index.ts");
+ const proc = spawn("npx", ["tsx", daemonEntry], { detached: true, stdio: "ignore" });
+ proc.unref();
+ await sleep(2000);
+
+ // Now socket should exist
+ try {
+ await access(sock);
+ console.log("✓ Socket exists after daemon spawn — tool wrapper proceeds silently");
+ } catch {
+ console.log("FAIL: Socket still missing after daemon spawn");
+ process.exit(1);
+ }
+
+ // Cleanup
+ proc.kill();
+ console.log("✓ All socket guard checks passed");
+}
+
+main();