diff --git a/packages/baoyu-chrome-cdp/src/index.test.ts b/packages/baoyu-chrome-cdp/src/index.test.ts index a3e93b4..f745da5 100644 --- a/packages/baoyu-chrome-cdp/src/index.test.ts +++ b/packages/baoyu-chrome-cdp/src/index.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import http from "node:http"; import os from "node:os"; @@ -7,9 +8,11 @@ import process from "node:process"; import test, { type TestContext } from "node:test"; import { + discoverRunningChromeDebugPort, findChromeExecutable, findExistingChromeDebugPort, getFreePort, + openPageSession, resolveSharedChromeProfileDir, waitForChromeDebugPort, } from "./index.ts"; @@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise { }); } +function shellPathForPlatform(): string | null { + if (process.platform === "win32") return null; + return "/bin/bash"; +} + +async function startFakeChromiumProcess(port: number): Promise { + const shell = shellPathForPlatform(); + if (!shell) return null; + + const child = spawn( + shell, + [ + "-lc", + `exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`, + ], + { stdio: "ignore" }, + ); + + await new Promise((resolve) => setTimeout(resolve, 250)); + return child; +} + +async function stopProcess(child: ChildProcess | null): Promise { + if (!child) return; + if (child.exitCode !== null || child.signalCode !== null) return; + + child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => child.once("exit", resolve)); +} + test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => { useEnv(t, { TEST_FIXED_PORT: "45678" }); assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678); @@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai assert.equal(found, port); }); +test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => { + const root = await makeTempDir("baoyu-cdp-user-data-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const port = await getFreePort(); + const server = await startDebugServer(port); + t.after(() => closeServer(server)); + + await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`); + + const found = await discoverRunningChromeDebugPort({ + userDataDirs: [root], + timeoutMs: 1000, + }); + assert.deepEqual(found, { + port, + wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`, + }); +}); + +test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => { + if (process.platform === "win32") { + t.skip("Process discovery fallback is not used on Windows."); + return; + } + + const root = await makeTempDir("baoyu-cdp-user-data-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const port = await getFreePort(); + const server = await startDebugServer(port); + t.after(() => closeServer(server)); + + const fakeChromium = await startFakeChromiumProcess(port); + t.after(async () => { await stopProcess(fakeChromium); }); + + const found = await discoverRunningChromeDebugPort({ + userDataDirs: [root], + timeoutMs: 1000, + }); + assert.equal(found, null); +}); + +test("openPageSession reports whether it created a new target", async () => { + const calls: string[] = []; + const cdpExisting = { + send: async (method: string): Promise => { + calls.push(method); + if (method === "Target.getTargets") { + return { + targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }], + } as T; + } + if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T; + throw new Error(`Unexpected method: ${method}`); + }, + }; + + const existing = await openPageSession({ + cdp: cdpExisting as never, + reusing: false, + url: "https://gemini.google.com/app", + matchTarget: (target) => target.url.includes("gemini.google.com"), + activateTarget: false, + }); + + assert.deepEqual(existing, { + sessionId: "session-existing", + targetId: "existing-target", + createdTarget: false, + }); + assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]); + + const createCalls: string[] = []; + const cdpCreated = { + send: async (method: string): Promise => { + createCalls.push(method); + if (method === "Target.getTargets") return { targetInfos: [] } as T; + if (method === "Target.createTarget") return { targetId: "created-target" } as T; + if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T; + throw new Error(`Unexpected method: ${method}`); + }, + }; + + const created = await openPageSession({ + cdp: cdpCreated as never, + reusing: false, + url: "https://gemini.google.com/app", + matchTarget: (target) => target.url.includes("gemini.google.com"), + activateTarget: false, + }); + + assert.deepEqual(created, { + sessionId: "session-created", + targetId: "created-target", + createdTarget: true, + }); + assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]); +}); + test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => { const port = await getFreePort(); diff --git a/packages/baoyu-chrome-cdp/src/index.ts b/packages/baoyu-chrome-cdp/src/index.ts index ee4fd71..56f4ebd 100644 --- a/packages/baoyu-chrome-cdp/src/index.ts +++ b/packages/baoyu-chrome-cdp/src/index.ts @@ -52,6 +52,7 @@ export type DiscoveredChrome = { type DiscoverRunningChromeOptions = { channels?: ChromeChannel[]; + userDataDirs?: string[]; timeoutMs?: number; }; @@ -85,6 +86,7 @@ type OpenPageSessionOptions = { export type PageSession = { sessionId: string; targetId: string; + createdTarget: boolean; }; export function sleep(ms: number): Promise { @@ -273,7 +275,8 @@ export async function discoverRunningChromeDebugPort(options: DiscoverRunningChr const channels = options.channels ?? ["stable", "beta", "canary", "dev"]; const timeoutMs = options.timeoutMs ?? 3_000; - const userDataDirs = getDefaultChromeUserDataDirs(channels); + const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels)) + .map((dir) => path.resolve(dir)); for (const dir of userDataDirs) { const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort")); if (!parsed) continue; @@ -288,7 +291,10 @@ export async function discoverRunningChromeDebugPort(options: DiscoverRunningChr if (result.status === 0 && result.stdout) { const lines = result.stdout .split("\n") - .filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line)); + .filter((line) => + line.includes("--remote-debugging-port=") && + userDataDirs.some((dir) => line.includes(dir)) + ); for (const line of lines) { const portMatch = line.match(/--remote-debugging-port=(\d+)/); @@ -479,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void { export async function openPageSession(options: OpenPageSessionOptions): Promise { let targetId: string; + let createdTarget = false; if (options.reusing) { const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url }); targetId = created.targetId; + createdTarget = true; } else { const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets"); const existing = targets.targetInfos.find(options.matchTarget); @@ -491,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise< } else { const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url }); targetId = created.targetId; + createdTarget = true; } } @@ -507,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise< if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId }); if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId }); - return { sessionId, targetId }; + return { sessionId, targetId, createdTarget }; } diff --git a/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts b/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts index 6b9a51a..6c06051 100644 --- a/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts +++ b/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts @@ -108,8 +108,8 @@ async function fetch_cookies_from_existing_chrome( if (verbose) logger.info(`Found existing Chrome on port ${discovered.port}. Connecting via WebSocket...`); let cdp: CdpConnection | null = null; - let createdTab = false; let targetId: string | null = null; + let createdTarget = false; try { const connectStart = Date.now(); const connectTimeout = 30_000; @@ -129,14 +129,6 @@ async function fetch_cookies_from_existing_chrome( return null; } - const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - const hasGeminiTab = targets.targetInfos.some( - (t) => t.type === 'page' && t.url.includes('gemini.google.com'), - ); - createdTab = !hasGeminiTab; - - if (verbose) logger.debug(hasGeminiTab ? 'Found existing Gemini tab, attaching...' : 'No Gemini tab found, creating new tab...'); - const page = await openPageSession({ cdp, reusing: false, @@ -147,6 +139,9 @@ async function fetch_cookies_from_existing_chrome( }); const { sessionId } = page; targetId = page.targetId; + createdTarget = page.createdTarget; + + if (verbose) logger.debug(createdTarget ? 'No Gemini tab found, creating new tab...' : 'Found existing Gemini tab, attaching...'); const start = Date.now(); let last: CookieMap = {}; @@ -176,7 +171,7 @@ async function fetch_cookies_from_existing_chrome( return null; } finally { if (cdp) { - if (createdTab && targetId) { + if (createdTarget && targetId) { try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {} } cdp.close(); diff --git a/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.test.ts b/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.test.ts index a3e93b4..f745da5 100644 --- a/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.test.ts +++ b/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import http from "node:http"; import os from "node:os"; @@ -7,9 +8,11 @@ import process from "node:process"; import test, { type TestContext } from "node:test"; import { + discoverRunningChromeDebugPort, findChromeExecutable, findExistingChromeDebugPort, getFreePort, + openPageSession, resolveSharedChromeProfileDir, waitForChromeDebugPort, } from "./index.ts"; @@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise { }); } +function shellPathForPlatform(): string | null { + if (process.platform === "win32") return null; + return "/bin/bash"; +} + +async function startFakeChromiumProcess(port: number): Promise { + const shell = shellPathForPlatform(); + if (!shell) return null; + + const child = spawn( + shell, + [ + "-lc", + `exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`, + ], + { stdio: "ignore" }, + ); + + await new Promise((resolve) => setTimeout(resolve, 250)); + return child; +} + +async function stopProcess(child: ChildProcess | null): Promise { + if (!child) return; + if (child.exitCode !== null || child.signalCode !== null) return; + + child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => child.once("exit", resolve)); +} + test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => { useEnv(t, { TEST_FIXED_PORT: "45678" }); assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678); @@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai assert.equal(found, port); }); +test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => { + const root = await makeTempDir("baoyu-cdp-user-data-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const port = await getFreePort(); + const server = await startDebugServer(port); + t.after(() => closeServer(server)); + + await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`); + + const found = await discoverRunningChromeDebugPort({ + userDataDirs: [root], + timeoutMs: 1000, + }); + assert.deepEqual(found, { + port, + wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`, + }); +}); + +test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => { + if (process.platform === "win32") { + t.skip("Process discovery fallback is not used on Windows."); + return; + } + + const root = await makeTempDir("baoyu-cdp-user-data-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const port = await getFreePort(); + const server = await startDebugServer(port); + t.after(() => closeServer(server)); + + const fakeChromium = await startFakeChromiumProcess(port); + t.after(async () => { await stopProcess(fakeChromium); }); + + const found = await discoverRunningChromeDebugPort({ + userDataDirs: [root], + timeoutMs: 1000, + }); + assert.equal(found, null); +}); + +test("openPageSession reports whether it created a new target", async () => { + const calls: string[] = []; + const cdpExisting = { + send: async (method: string): Promise => { + calls.push(method); + if (method === "Target.getTargets") { + return { + targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }], + } as T; + } + if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T; + throw new Error(`Unexpected method: ${method}`); + }, + }; + + const existing = await openPageSession({ + cdp: cdpExisting as never, + reusing: false, + url: "https://gemini.google.com/app", + matchTarget: (target) => target.url.includes("gemini.google.com"), + activateTarget: false, + }); + + assert.deepEqual(existing, { + sessionId: "session-existing", + targetId: "existing-target", + createdTarget: false, + }); + assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]); + + const createCalls: string[] = []; + const cdpCreated = { + send: async (method: string): Promise => { + createCalls.push(method); + if (method === "Target.getTargets") return { targetInfos: [] } as T; + if (method === "Target.createTarget") return { targetId: "created-target" } as T; + if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T; + throw new Error(`Unexpected method: ${method}`); + }, + }; + + const created = await openPageSession({ + cdp: cdpCreated as never, + reusing: false, + url: "https://gemini.google.com/app", + matchTarget: (target) => target.url.includes("gemini.google.com"), + activateTarget: false, + }); + + assert.deepEqual(created, { + sessionId: "session-created", + targetId: "created-target", + createdTarget: true, + }); + assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]); +}); + test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => { const port = await getFreePort(); diff --git a/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts index ee4fd71..56f4ebd 100644 --- a/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts +++ b/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts @@ -52,6 +52,7 @@ export type DiscoveredChrome = { type DiscoverRunningChromeOptions = { channels?: ChromeChannel[]; + userDataDirs?: string[]; timeoutMs?: number; }; @@ -85,6 +86,7 @@ type OpenPageSessionOptions = { export type PageSession = { sessionId: string; targetId: string; + createdTarget: boolean; }; export function sleep(ms: number): Promise { @@ -273,7 +275,8 @@ export async function discoverRunningChromeDebugPort(options: DiscoverRunningChr const channels = options.channels ?? ["stable", "beta", "canary", "dev"]; const timeoutMs = options.timeoutMs ?? 3_000; - const userDataDirs = getDefaultChromeUserDataDirs(channels); + const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels)) + .map((dir) => path.resolve(dir)); for (const dir of userDataDirs) { const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort")); if (!parsed) continue; @@ -288,7 +291,10 @@ export async function discoverRunningChromeDebugPort(options: DiscoverRunningChr if (result.status === 0 && result.stdout) { const lines = result.stdout .split("\n") - .filter((line) => line.includes("--remote-debugging-port=") && /chrome|chromium/i.test(line)); + .filter((line) => + line.includes("--remote-debugging-port=") && + userDataDirs.some((dir) => line.includes(dir)) + ); for (const line of lines) { const portMatch = line.match(/--remote-debugging-port=(\d+)/); @@ -479,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void { export async function openPageSession(options: OpenPageSessionOptions): Promise { let targetId: string; + let createdTarget = false; if (options.reusing) { const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url }); targetId = created.targetId; + createdTarget = true; } else { const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets"); const existing = targets.targetInfos.find(options.matchTarget); @@ -491,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise< } else { const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url }); targetId = created.targetId; + createdTarget = true; } } @@ -507,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise< if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId }); if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId }); - return { sessionId, targetId }; + return { sessionId, targetId, createdTarget }; }