mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e54a7b86 | |||
| c1f8a9ad07 | |||
| 8b8ecf61a6 | |||
| 4be6f3682a | |||
| b89ef02221 | |||
| 55356c8820 | |||
| f23d4eebc3 | |||
| 3b795b6ef3 | |||
| c62d9d5a1b | |||
| 84c56b0da3 | |||
| 462d080a0e | |||
| bd01c370c9 | |||
| b7bcc8c094 | |||
| d9f9da639d | |||
| 126b3040e6 | |||
| 682888cc95 | |||
| e31294415d | |||
| 926f74e9c9 | |||
| 339990e87e | |||
| 3c5c3e006d | |||
| 2aa9790789 | |||
| 38fc733b99 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.67.0"
|
"version": "1.69.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.69.1 - 2026-03-16
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-chrome-cdp`: tighten chrome auto-connect logic to reduce false positives
|
||||||
|
|
||||||
|
## 1.69.0 - 2026-03-16
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-chrome-cdp`: support connecting to existing Chrome session (by @bviews)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-chrome-cdp`: support Chrome 146 native remote debugging in approval mode (by @bviews)
|
||||||
|
- `baoyu-chrome-cdp`: keep HTTP validation in findExistingChromeDebugPort (by @bviews)
|
||||||
|
- `baoyu-danger-gemini-web`: reuse openPageSession and fix orphaned tab leak (by @bviews)
|
||||||
|
- `baoyu-danger-gemini-web`: respect explicit profile config over auto-discovery (by @bviews)
|
||||||
|
- `baoyu-danger-gemini-web`: respect BAOYU_CHROME_PROFILE_DIR in auto-discovery skip (by @bviews)
|
||||||
|
- `baoyu-post-to-wechat`: improve browser publishing reliability (by @cfh-7598)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-cover-image`: clarify people reference image workflow and interactive confirmation
|
||||||
|
|
||||||
|
## 1.68.0 - 2026-03-14
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-article-illustrator`: add configurable output directory (`default_output_dir`) with 4 options — `imgs-subdir`, `same-dir`, `illustrations-subdir`, `independent`
|
||||||
|
- `baoyu-cover-image`: add character preservation from reference images — use `usage: direct` to pass people references to model for stylized likeness
|
||||||
|
|
||||||
## 1.67.0 - 2026-03-13
|
## 1.67.0 - 2026-03-13
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.69.1 - 2026-03-16
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-chrome-cdp`:收紧 Chrome 自动连接逻辑,减少误连接
|
||||||
|
|
||||||
|
## 1.69.0 - 2026-03-16
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-chrome-cdp`:支持连接到已有的 Chrome 会话 (by @bviews)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-chrome-cdp`:支持 Chrome 146 原生远程调试(审批模式)(by @bviews)
|
||||||
|
- `baoyu-chrome-cdp`:保留 findExistingChromeDebugPort 中的 HTTP 验证 (by @bviews)
|
||||||
|
- `baoyu-danger-gemini-web`:复用 openPageSession 并修复孤立标签页泄漏 (by @bviews)
|
||||||
|
- `baoyu-danger-gemini-web`:显式配置优先于自动发现 (by @bviews)
|
||||||
|
- `baoyu-danger-gemini-web`:自动发现跳过时也遵循 BAOYU_CHROME_PROFILE_DIR (by @bviews)
|
||||||
|
- `baoyu-post-to-wechat`:提升浏览器发布可靠性 (by @cfh-7598)
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-cover-image`:完善人物参考图片工作流和交互式确认说明
|
||||||
|
|
||||||
|
## 1.68.0 - 2026-03-14
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-article-illustrator`:新增可配置输出目录(`default_output_dir`),支持 4 种选项——`imgs-subdir`、`same-dir`、`illustrations-subdir`、`independent`
|
||||||
|
- `baoyu-cover-image`:新增参考图片人物保留功能——当参考图包含人物时使用 `usage: direct` 传递给模型,风格化保留人物特征
|
||||||
|
|
||||||
## 1.67.0 - 2026-03-13
|
## 1.67.0 - 2026-03-13
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.67.0**.
|
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.69.1**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||||
|
|
||||||
|
const render = (md: string) => {
|
||||||
|
const r = initRenderer();
|
||||||
|
return renderMarkdown(md, r).html;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("bold with inline code (no underscore)", () => {
|
||||||
|
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||||
|
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||||
|
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code (contains underscore)", () => {
|
||||||
|
const html = render("**变成 `input_ids`。**");
|
||||||
|
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code", () => {
|
||||||
|
const html = render("*查看 `hidden_states`*");
|
||||||
|
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain inline code (regression)", () => {
|
||||||
|
const html = render("`lm_head`");
|
||||||
|
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold without code (regression)", () => {
|
||||||
|
const html = render("**纯粗体文本**");
|
||||||
|
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||||
|
assert.doesNotMatch(html, /<code/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing backticks", () => {
|
||||||
|
const html = render("**``a`b``**");
|
||||||
|
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code containing backticks", () => {
|
||||||
|
const html = render("*``a`b``*");
|
||||||
|
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing consecutive backticks", () => {
|
||||||
|
const html = render("**```a``b```**");
|
||||||
|
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only backticks", () => {
|
||||||
|
const html = render("**```` `` ````**");
|
||||||
|
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only spaces", () => {
|
||||||
|
const oneSpace = render("**`` ``**");
|
||||||
|
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||||
|
|
||||||
|
const twoSpaces = render("**`` ``**");
|
||||||
|
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||||
|
});
|
||||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapInlineCode(value: string): string {
|
||||||
|
const runs = value.match(/`+/g);
|
||||||
|
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||||
|
const padding = /^ *$/.test(value) ? "" : " ";
|
||||||
|
return `${fence}${padding}${value}${padding}${fence}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||||
const footnotes: [number, string, string][] = [];
|
const footnotes: [number, string, string][] = [];
|
||||||
let footnoteIndex = 0;
|
let footnoteIndex = 0;
|
||||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
|||||||
const tree = processor.parse(markdown);
|
const tree = processor.parse(markdown);
|
||||||
const extractText = (node: any): string => {
|
const extractText = (node: any): string => {
|
||||||
if (node.type === "text") return node.value;
|
if (node.type === "text") return node.value;
|
||||||
|
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||||
if (node.children) return node.children.map(extractText).join("");
|
if (node.children) return node.children.map(extractText).join("");
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ Full procedures: [references/workflow.md](references/workflow.md#step-5-generate
|
|||||||
|
|
||||||
### Step 6: Finalize
|
### Step 6: Finalize
|
||||||
|
|
||||||
Insert `` after paragraphs.
|
Insert `` after paragraphs. Path computed relative to article file based on output directory setting.
|
||||||
|
|
||||||
```
|
```
|
||||||
Article Illustration Complete!
|
Article Illustration Complete!
|
||||||
@@ -143,15 +143,27 @@ Images: X/N generated
|
|||||||
|
|
||||||
## Output Directory
|
## Output Directory
|
||||||
|
|
||||||
|
Output directory is determined by `default_output_dir` in EXTEND.md (set during first-time setup):
|
||||||
|
|
||||||
|
| `default_output_dir` | Output Path | Markdown Insert Path |
|
||||||
|
|----------------------|-------------|----------------------|
|
||||||
|
| `imgs-subdir` (default) | `{article-dir}/imgs/` | `imgs/NN-{type}-{slug}.png` |
|
||||||
|
| `same-dir` | `{article-dir}/` | `NN-{type}-{slug}.png` |
|
||||||
|
| `illustrations-subdir` | `{article-dir}/illustrations/` | `illustrations/NN-{type}-{slug}.png` |
|
||||||
|
| `independent` | `illustrations/{topic-slug}/` | `illustrations/{topic-slug}/NN-{type}-{slug}.png` (relative to cwd) |
|
||||||
|
|
||||||
|
All auxiliary files (outline, prompts) are saved inside the output directory:
|
||||||
|
|
||||||
```
|
```
|
||||||
illustrations/{topic-slug}/
|
{output-dir}/
|
||||||
├── source-{slug}.{ext}
|
|
||||||
├── references/ # if provided
|
|
||||||
├── outline.md
|
├── outline.md
|
||||||
├── prompts/
|
├── prompts/
|
||||||
|
│ └── NN-{type}-{slug}.md
|
||||||
└── NN-{type}-{slug}.png
|
└── NN-{type}-{slug}.png
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When input is **pasted content** (no file path), always uses `illustrations/{topic-slug}/` with `source-{slug}.{ext}` saved alongside.
|
||||||
|
|
||||||
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||||
|
|
||||||
## Modification
|
## Modification
|
||||||
|
|||||||
@@ -69,7 +69,23 @@ options:
|
|||||||
description: "Friendly, approachable, personal"
|
description: "Friendly, approachable, personal"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Question 3: Save Location
|
### Question 3: Output Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
header: "Output Directory"
|
||||||
|
question: "Where to save generated illustrations when illustrating a file?"
|
||||||
|
options:
|
||||||
|
- label: "imgs-subdir (Recommended)"
|
||||||
|
description: "{article-dir}/imgs/ — images in a subdirectory next to the article"
|
||||||
|
- label: "same-dir"
|
||||||
|
description: "{article-dir}/ — images alongside the article file"
|
||||||
|
- label: "illustrations-subdir"
|
||||||
|
description: "{article-dir}/illustrations/ — separate illustrations subdirectory"
|
||||||
|
- label: "independent"
|
||||||
|
description: "illustrations/{topic-slug}/ — standalone directory in cwd"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 4: Save Location
|
||||||
|
|
||||||
```
|
```
|
||||||
header: "Save"
|
header: "Save"
|
||||||
@@ -108,6 +124,7 @@ watermark:
|
|||||||
preferred_style:
|
preferred_style:
|
||||||
name: [selected style or null]
|
name: [selected style or null]
|
||||||
description: ""
|
description: ""
|
||||||
|
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
|
||||||
language: null
|
language: null
|
||||||
custom_styles: []
|
custom_styles: []
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Reference Style Extracted (no file):
|
|||||||
|
|
||||||
| Input | Output Directory | Next |
|
| Input | Output Directory | Next |
|
||||||
|-------|------------------|------|
|
|-------|------------------|------|
|
||||||
| File path | Ask user (1.2) | → 1.2 |
|
| File path | EXTEND.md `default_output_dir` (default: `imgs-subdir`). If not configured, confirm in 1.2. | → 1.2 |
|
||||||
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
||||||
|
|
||||||
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
|
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
|
||||||
@@ -68,7 +68,7 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
|
|||||||
|
|
||||||
| Question | When to Ask | Options |
|
| Question | When to Ask | Options |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/`, `{article-dir}/imgs/` (Recommended), `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/imgs/` (Recommended), `{article-dir}/`, `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
||||||
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
|
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
|
||||||
| Article update | Always (file path input) | `update`, `copy` |
|
| Article update | Always (file path input) | `update`, `copy` |
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ Reference Images:
|
|||||||
|
|
||||||
## Step 4: Generate Outline
|
## Step 4: Generate Outline
|
||||||
|
|
||||||
Save as `outline.md`:
|
Save as `{output-dir}/outline.md` (all paths below are relative to the output directory determined in Step 1.1/1.2):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -285,7 +285,7 @@ references: # Only if references provided
|
|||||||
|
|
||||||
For each illustration in the outline:
|
For each illustration in the outline:
|
||||||
|
|
||||||
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
|
1. **Create prompt file**: `{output-dir}/prompts/NN-{type}-{slug}.md`
|
||||||
2. **Include YAML frontmatter**:
|
2. **Include YAML frontmatter**:
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -381,10 +381,14 @@ Add: `Include a subtle watermark "[content]" at [position].`
|
|||||||
|
|
||||||
### 6.1 Update Article
|
### 6.1 Update Article
|
||||||
|
|
||||||
Insert after corresponding paragraph:
|
Insert after corresponding paragraph, using path relative to article file:
|
||||||
```markdown
|
|
||||||

|
| `default_output_dir` | Insert Path |
|
||||||
```
|
|----------------------|-------------|
|
||||||
|
| `imgs-subdir` | `` |
|
||||||
|
| `same-dir` | `` |
|
||||||
|
| `illustrations-subdir` | `` |
|
||||||
|
| `independent` | `` (relative to cwd) |
|
||||||
|
|
||||||
Alt text: concise description in article's language.
|
Alt text: concise description in article's language.
|
||||||
|
|
||||||
|
|||||||
@@ -162,9 +162,21 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
|||||||
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||||
6. **Determine output directory**: Per File Structure rules
|
6. **Determine output directory**: Per File Structure rules
|
||||||
|
|
||||||
|
**⚠️ People in Reference Images — MUST follow all 3 rules:**
|
||||||
|
|
||||||
|
If reference images contain **people** who should appear in the cover:
|
||||||
|
|
||||||
|
1. **`usage: direct`** — MUST set in refs description file. NEVER use `style` or `palette` when people need to appear
|
||||||
|
2. **Per-character description** — MUST describe each person's distinctive features (hair, glasses, skin tone, clothing) in `refs/ref-NN-{slug}.md`. Vague descriptions like "a man" will fail
|
||||||
|
3. **`--ref` flag** — MUST pass reference image via `--ref` in Step 4 so the model sees actual faces
|
||||||
|
|
||||||
|
See [reference-images.md § Character Analysis](references/workflow/reference-images.md) for description format.
|
||||||
|
|
||||||
### Step 2: Confirm Options ⚠️
|
### Step 2: Confirm Options ⚠️
|
||||||
|
|
||||||
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
**MUST use `AskUserQuestion` tool** to present options as interactive selection — NOT plain text tables. Present up to 4 questions in a single `AskUserQuestion` call (Type, Palette, Rendering, Font + Settings). Each question shows the recommended option first with reason, followed by alternatives.
|
||||||
|
|
||||||
|
Full confirmation flow and question format: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||||
|
|
||||||
| Condition | Skipped | Still Asked |
|
| Condition | Skipped | Still Asked |
|
||||||
|-----------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
|
|||||||
@@ -83,12 +83,17 @@ Full library: [references/visual-elements.md](visual-elements.md)
|
|||||||
|
|
||||||
### Character Handling
|
### Character Handling
|
||||||
|
|
||||||
When people are needed:
|
**Default (no reference with people)**:
|
||||||
- Use simplified silhouettes or abstract stick figures
|
- Use simplified silhouettes or abstract stick figures
|
||||||
- Symbolic representations (head + shoulders outline)
|
- Symbolic representations (head + shoulders outline)
|
||||||
- NO realistic faces, detailed anatomy, or photographic representations
|
- NO realistic faces, detailed anatomy, or photographic representations
|
||||||
- Cartoon/icon style consistent with rendering choice
|
- Cartoon/icon style consistent with rendering choice
|
||||||
|
|
||||||
|
**When reference images contain people**:
|
||||||
|
- Reference image is passed to model (`usage: direct`) — model must visually reference it to preserve character likeness
|
||||||
|
- Stylize to match chosen rendering (cartoon/vector), preserving distinctive features (hair, clothing, pose)
|
||||||
|
- NEVER photorealistic
|
||||||
|
|
||||||
## Mood Application
|
## Mood Application
|
||||||
|
|
||||||
Apply mood adjustments to the base palette:
|
Apply mood adjustments to the base palette:
|
||||||
|
|||||||
@@ -201,6 +201,12 @@ CRITICAL: The generated cover MUST visually reference the provided images. The c
|
|||||||
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
||||||
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
||||||
|
|
||||||
|
## From Ref 1 ([filename]) — Characters (if people present):
|
||||||
|
- **Character 1**: [Appearance, e.g., "Woman, long wavy blonde hair"] → MUST stylize: [e.g., "flat-vector, simplified face, keep blonde hair, label: 'Nicole Forsgren'"]
|
||||||
|
- **Character 2**: [Appearance, e.g., "Man, short dark hair, stubble"] → MUST stylize: [e.g., "flat-vector, simplified face, keep dark hair, label: 'Gergely Orosz'"]
|
||||||
|
- **Placement**: [e.g., "Right third, side by side, facing left toward main visual"]
|
||||||
|
- **Style**: Match rendering style, NOT photorealistic
|
||||||
|
|
||||||
## From Ref 2 ([filename]) — REQUIRED elements:
|
## From Ref 2 ([filename]) — REQUIRED elements:
|
||||||
[Same detailed breakdown]
|
[Same detailed breakdown]
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ usage: direct | style | palette
|
|||||||
|
|
||||||
| Usage | When to Use |
|
| Usage | When to Use |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `direct` | Reference matches desired output closely |
|
| `direct` | Model sees reference image directly; required if people must appear in output |
|
||||||
| `style` | Extract visual style characteristics only |
|
| `style` | Extract visual style only (not for people who must appear) |
|
||||||
| `palette` | Extract color scheme only |
|
| `palette` | Extract color scheme only |
|
||||||
|
|
||||||
## Verbal Extraction (No File)
|
## Verbal Extraction (No File)
|
||||||
@@ -59,6 +59,19 @@ References are high-priority inputs. Extract **specific, concrete, reproducible*
|
|||||||
|
|
||||||
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
|
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
|
||||||
|
|
||||||
|
### Character Analysis ⚠️ If Reference Contains People
|
||||||
|
|
||||||
|
Use `usage: direct` so model sees the reference image. Additionally describe per character: **appearance**, **pose**, **clothing** → with **transformation rules** (stylize to match rendering).
|
||||||
|
|
||||||
|
| Extract | Good | Bad |
|
||||||
|
|---------|------|-----|
|
||||||
|
| Appearance | "Woman: long wavy blonde hair, friendly smile" | "A woman" |
|
||||||
|
| Pose | "Standing, facing camera, confident posture" | "Standing" |
|
||||||
|
| Clothing | "Dark T-shirt, business casual" | "Formal" |
|
||||||
|
| Transform | "Flat-vector cartoon, keep hair color & clothing" | "Make cartoon" |
|
||||||
|
|
||||||
|
Use `usage: direct`. Output each character as MUST/REQUIRED prompt instruction.
|
||||||
|
|
||||||
## Verification Output
|
## Verification Output
|
||||||
|
|
||||||
**For saved files**:
|
**For saved files**:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import process from 'node:process';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable as findChromeExecutableBase,
|
findChromeExecutable as findChromeExecutableBase,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
@@ -97,6 +98,87 @@ async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetch_cookies_from_existing_chrome(
|
||||||
|
timeoutMs: number,
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<CookieMap | null> {
|
||||||
|
const discovered = await discoverRunningChromeDebugPort();
|
||||||
|
if (discovered === null) return null;
|
||||||
|
|
||||||
|
if (verbose) logger.info(`Found existing Chrome on port ${discovered.port}. Connecting via WebSocket...`);
|
||||||
|
|
||||||
|
let cdp: CdpConnection | null = null;
|
||||||
|
let targetId: string | null = null;
|
||||||
|
let createdTarget = false;
|
||||||
|
try {
|
||||||
|
const connectStart = Date.now();
|
||||||
|
const connectTimeout = 30_000;
|
||||||
|
let lastConnErr: unknown = null;
|
||||||
|
while (Date.now() - connectStart < connectTimeout) {
|
||||||
|
try {
|
||||||
|
cdp = await CdpConnection.connect(discovered.wsUrl, 5_000);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastConnErr = e;
|
||||||
|
if (verbose) logger.debug(`WebSocket connect attempt failed: ${e instanceof Error ? e.message : String(e)}, retrying...`);
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cdp) {
|
||||||
|
if (verbose) logger.debug(`Could not connect to Chrome after ${connectTimeout / 1000}s: ${lastConnErr instanceof Error ? lastConnErr.message : String(lastConnErr)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await openPageSession({
|
||||||
|
cdp,
|
||||||
|
reusing: false,
|
||||||
|
url: GEMINI_APP_URL,
|
||||||
|
matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'),
|
||||||
|
enableNetwork: true,
|
||||||
|
activateTarget: false,
|
||||||
|
});
|
||||||
|
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 = {};
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
|
||||||
|
'Network.getCookies',
|
||||||
|
{ urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] },
|
||||||
|
{ sessionId, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const cookieMap: CookieMap = {};
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
last = cookieMap;
|
||||||
|
if (await is_gemini_session_ready(cookieMap, verbose)) return cookieMap;
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) logger.debug(`Existing Chrome did not yield valid cookies. Last keys: ${Object.keys(last).join(', ')}`);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (verbose) logger.debug(`Failed to connect to existing Chrome: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (cdp) {
|
||||||
|
if (createdTarget && targetId) {
|
||||||
|
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||||
|
}
|
||||||
|
cdp.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetch_google_cookies_via_cdp(
|
async function fetch_google_cookies_via_cdp(
|
||||||
profileDir: string,
|
profileDir: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -178,6 +260,19 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo
|
|||||||
if (cached) return { chrome: cached };
|
if (cached) return { chrome: cached };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasExplicitProfile = !!(process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || process.env.BAOYU_CHROME_PROFILE_DIR?.trim());
|
||||||
|
const existingCookies = hasExplicitProfile ? null : await fetch_cookies_from_existing_chrome(30_000, verbose);
|
||||||
|
if (existingCookies) {
|
||||||
|
const filtered: CookieMap = {};
|
||||||
|
for (const [key, value] of Object.entries(existingCookies)) {
|
||||||
|
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp-existing');
|
||||||
|
void domain_name;
|
||||||
|
return { chrome: filtered };
|
||||||
|
}
|
||||||
|
|
||||||
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
|
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
|
||||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||||
|
|
||||||
|
|||||||
+136
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
+120
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
+120
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||||
|
|
||||||
|
const render = (md: string) => {
|
||||||
|
const r = initRenderer();
|
||||||
|
return renderMarkdown(md, r).html;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("bold with inline code (no underscore)", () => {
|
||||||
|
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||||
|
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||||
|
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code (contains underscore)", () => {
|
||||||
|
const html = render("**变成 `input_ids`。**");
|
||||||
|
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code", () => {
|
||||||
|
const html = render("*查看 `hidden_states`*");
|
||||||
|
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain inline code (regression)", () => {
|
||||||
|
const html = render("`lm_head`");
|
||||||
|
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold without code (regression)", () => {
|
||||||
|
const html = render("**纯粗体文本**");
|
||||||
|
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||||
|
assert.doesNotMatch(html, /<code/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing backticks", () => {
|
||||||
|
const html = render("**``a`b``**");
|
||||||
|
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code containing backticks", () => {
|
||||||
|
const html = render("*``a`b``*");
|
||||||
|
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing consecutive backticks", () => {
|
||||||
|
const html = render("**```a``b```**");
|
||||||
|
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only backticks", () => {
|
||||||
|
const html = render("**```` `` ````**");
|
||||||
|
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only spaces", () => {
|
||||||
|
const oneSpace = render("**`` ``**");
|
||||||
|
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||||
|
|
||||||
|
const twoSpaces = render("**`` ``**");
|
||||||
|
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||||
|
});
|
||||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapInlineCode(value: string): string {
|
||||||
|
const runs = value.match(/`+/g);
|
||||||
|
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||||
|
const padding = /^ *$/.test(value) ? "" : " ";
|
||||||
|
return `${fence}${padding}${value}${padding}${fence}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||||
const footnotes: [number, string, string][] = [];
|
const footnotes: [number, string, string][] = [];
|
||||||
let footnoteIndex = 0;
|
let footnoteIndex = 0;
|
||||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
|||||||
const tree = processor.parse(markdown);
|
const tree = processor.parse(markdown);
|
||||||
const extractText = (node: any): string => {
|
const extractText = (node: any): string => {
|
||||||
if (node.type === "text") return node.value;
|
if (node.type === "text") return node.value;
|
||||||
|
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||||
if (node.children) return node.children.map(extractText).join("");
|
if (node.children) return node.children.map(extractText).join("");
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
+136
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||||
|
|
||||||
|
const render = (md: string) => {
|
||||||
|
const r = initRenderer();
|
||||||
|
return renderMarkdown(md, r).html;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("bold with inline code (no underscore)", () => {
|
||||||
|
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||||
|
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||||
|
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code (contains underscore)", () => {
|
||||||
|
const html = render("**变成 `input_ids`。**");
|
||||||
|
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code", () => {
|
||||||
|
const html = render("*查看 `hidden_states`*");
|
||||||
|
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain inline code (regression)", () => {
|
||||||
|
const html = render("`lm_head`");
|
||||||
|
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold without code (regression)", () => {
|
||||||
|
const html = render("**纯粗体文本**");
|
||||||
|
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||||
|
assert.doesNotMatch(html, /<code/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing backticks", () => {
|
||||||
|
const html = render("**``a`b``**");
|
||||||
|
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code containing backticks", () => {
|
||||||
|
const html = render("*``a`b``*");
|
||||||
|
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing consecutive backticks", () => {
|
||||||
|
const html = render("**```a``b```**");
|
||||||
|
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only backticks", () => {
|
||||||
|
const html = render("**```` `` ````**");
|
||||||
|
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only spaces", () => {
|
||||||
|
const oneSpace = render("**`` ``**");
|
||||||
|
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||||
|
|
||||||
|
const twoSpaces = render("**`` ``**");
|
||||||
|
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||||
|
});
|
||||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapInlineCode(value: string): string {
|
||||||
|
const runs = value.match(/`+/g);
|
||||||
|
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||||
|
const padding = /^ *$/.test(value) ? "" : " ";
|
||||||
|
return `${fence}${padding}${value}${padding}${fence}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||||
const footnotes: [number, string, string][] = [];
|
const footnotes: [number, string, string][] = [];
|
||||||
let footnoteIndex = 0;
|
let footnoteIndex = 0;
|
||||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
|||||||
const tree = processor.parse(markdown);
|
const tree = processor.parse(markdown);
|
||||||
const extractText = (node: any): string => {
|
const extractText = (node: any): string => {
|
||||||
if (node.type === "text") return node.value;
|
if (node.type === "text") return node.value;
|
||||||
|
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||||
if (node.children) return node.children.map(extractText).join("");
|
if (node.children) return node.children.map(extractText).join("");
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,32 +51,42 @@ async function waitForElement(session: ChromeSession, selector: string, timeoutM
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
|
async function clickMenuByText(session: ChromeSession, text: string, maxRetries = 5): Promise<void> {
|
||||||
console.log(`[wechat] Clicking "${text}" menu...`);
|
console.log(`[wechat] Clicking "${text}" menu...`);
|
||||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
expression: `
|
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
(function() {
|
expression: `
|
||||||
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
(function() {
|
||||||
for (const item of items) {
|
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||||
const title = item.querySelector('.new-creation__menu-title');
|
for (const item of items) {
|
||||||
if (title && title.textContent?.trim() === '${text}') {
|
const title = item.querySelector('.new-creation__menu-title');
|
||||||
item.scrollIntoView({ block: 'center' });
|
if (title && title.textContent?.trim() === '${text}') {
|
||||||
const rect = item.getBoundingClientRect();
|
item.scrollIntoView({ block: 'center' });
|
||||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
const rect = item.getBoundingClientRect();
|
||||||
|
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return 'null';
|
||||||
return 'null';
|
})()
|
||||||
})()
|
`,
|
||||||
`,
|
returnByValue: true,
|
||||||
returnByValue: true,
|
}, { sessionId: session.sessionId });
|
||||||
}, { sessionId: session.sessionId });
|
|
||||||
|
|
||||||
if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`);
|
if (posResult.result.value !== 'null') {
|
||||||
const pos = JSON.parse(posResult.result.value);
|
const pos = JSON.parse(posResult.result.value);
|
||||||
|
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||||
|
await sleep(100);
|
||||||
|
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
if (attempt < maxRetries) {
|
||||||
await sleep(100);
|
const delay = Math.min(1000 * attempt, 3000);
|
||||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
console.log(`[wechat] Menu "${text}" not found, retrying in ${delay}ms (${attempt}/${maxRetries})...`);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Menu "${text}" not found after ${maxRetries} attempts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyImageToClipboard(imagePath: string): Promise<void> {
|
async function copyImageToClipboard(imagePath: string): Promise<void> {
|
||||||
@@ -495,10 +505,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
if (!loggedIn) throw new Error('Login timeout');
|
if (!loggedIn) throw new Error('Login timeout');
|
||||||
}
|
}
|
||||||
console.log('[wechat] Logged in.');
|
console.log('[wechat] Logged in.');
|
||||||
await sleep(2000);
|
await sleep(5000);
|
||||||
|
|
||||||
// Wait for menu to be ready
|
// Wait for menu to be ready
|
||||||
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
|
const menuReady = await waitForElement(session, '.new-creation__menu', 40_000);
|
||||||
if (!menuReady) throw new Error('Home page menu did not load');
|
if (!menuReady) throw new Error('Home page menu did not load');
|
||||||
|
|
||||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||||
@@ -517,16 +527,21 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||||
await cdp.send('DOM.enable', {}, { sessionId });
|
await cdp.send('DOM.enable', {}, { sessionId });
|
||||||
|
|
||||||
await sleep(3000);
|
// Wait for editor elements to fully load
|
||||||
|
console.log('[wechat] Waiting for editor to load...');
|
||||||
|
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
||||||
|
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
||||||
|
await waitForElement(session, '.ProseMirror', 15_000);
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
if (effectiveTitle) {
|
if (effectiveTitle) {
|
||||||
console.log('[wechat] Filling title...');
|
console.log('[wechat] Filling title...');
|
||||||
await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`);
|
await evaluate(session, `(function() { const el = document.querySelector('#title'); el.focus(); el.value = ${JSON.stringify(effectiveTitle)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveAuthor) {
|
if (effectiveAuthor) {
|
||||||
console.log('[wechat] Filling author...');
|
console.log('[wechat] Filling author...');
|
||||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
await evaluate(session, `(function() { const el = document.querySelector('#author'); el.focus(); el.value = ${JSON.stringify(effectiveAuthor)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|||||||
+136
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||||
|
|
||||||
|
const render = (md: string) => {
|
||||||
|
const r = initRenderer();
|
||||||
|
return renderMarkdown(md, r).html;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("bold with inline code (no underscore)", () => {
|
||||||
|
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||||
|
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||||
|
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code (contains underscore)", () => {
|
||||||
|
const html = render("**变成 `input_ids`。**");
|
||||||
|
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code", () => {
|
||||||
|
const html = render("*查看 `hidden_states`*");
|
||||||
|
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain inline code (regression)", () => {
|
||||||
|
const html = render("`lm_head`");
|
||||||
|
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold without code (regression)", () => {
|
||||||
|
const html = render("**纯粗体文本**");
|
||||||
|
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||||
|
assert.doesNotMatch(html, /<code/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing backticks", () => {
|
||||||
|
const html = render("**``a`b``**");
|
||||||
|
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emphasis with inline code containing backticks", () => {
|
||||||
|
const html = render("*``a`b``*");
|
||||||
|
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing consecutive backticks", () => {
|
||||||
|
const html = render("**```a``b```**");
|
||||||
|
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only backticks", () => {
|
||||||
|
const html = render("**```` `` ````**");
|
||||||
|
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bold with inline code containing only spaces", () => {
|
||||||
|
const oneSpace = render("**`` ``**");
|
||||||
|
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||||
|
|
||||||
|
const twoSpaces = render("**`` ``**");
|
||||||
|
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||||
|
});
|
||||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapInlineCode(value: string): string {
|
||||||
|
const runs = value.match(/`+/g);
|
||||||
|
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||||
|
const padding = /^ *$/.test(value) ? "" : " ";
|
||||||
|
return `${fence}${padding}${value}${padding}${fence}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||||
const footnotes: [number, string, string][] = [];
|
const footnotes: [number, string, string][] = [];
|
||||||
let footnoteIndex = 0;
|
let footnoteIndex = 0;
|
||||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
|||||||
const tree = processor.parse(markdown);
|
const tree = processor.parse(markdown);
|
||||||
const extractText = (node: any): string => {
|
const extractText = (node: any): string => {
|
||||||
if (node.type === "text") return node.value;
|
if (node.type === "text") return node.value;
|
||||||
|
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||||
if (node.children) return node.children.map(extractText).join("");
|
if (node.children) return node.children.map(extractText).join("");
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
|||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
discoverRunningChromeDebugPort,
|
||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
openPageSession,
|
||||||
resolveSharedChromeProfileDir,
|
resolveSharedChromeProfileDir,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPathForPlatform(): string | null {
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
return "/bin/bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||||
|
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<void> {
|
||||||
|
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) => {
|
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||||
assert.equal(await getFreePort("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);
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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 <T>(method: string): Promise<T> => {
|
||||||
|
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) => {
|
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|
||||||
|
|||||||
+120
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||||
|
|
||||||
|
export type DiscoveredChrome = {
|
||||||
|
port: number;
|
||||||
|
wsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverRunningChromeOptions = {
|
||||||
|
channels?: ChromeChannel[];
|
||||||
|
userDataDirs?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LaunchChromeOptions = {
|
type LaunchChromeOptions = {
|
||||||
chromePath: string;
|
chromePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
|||||||
export type PageSession = {
|
export type PageSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
createdTarget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||||
|
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||||
|
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||||
|
socket.connect(port, "127.0.0.1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||||
|
const wsPath = lines[1]?.trim();
|
||||||
|
if (port > 0 && wsPath) return { port, wsPath };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||||
|
|
||||||
try {
|
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||||
const content = fs.readFileSync(portFile, "utf-8");
|
|
||||||
const [portLine] = content.split(/\r?\n/);
|
|
||||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
|
||||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (process.platform === "win32") return null;
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
@@ -204,6 +234,85 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||||
|
stable: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||||
|
},
|
||||||
|
canary: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||||
|
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||||
|
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const entry = channelDirs[ch];
|
||||||
|
if (entry) dirs.push(entry[platform]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||||
|
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||||
|
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.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+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||||
|
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function waitForChromeDebugPort(
|
||||||
port: number,
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -376,10 +485,12 @@ export function killChrome(chrome: ChildProcess): void {
|
|||||||
|
|
||||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
|
let createdTarget = false;
|
||||||
|
|
||||||
if (options.reusing) {
|
if (options.reusing) {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
} else {
|
} else {
|
||||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
const existing = targets.targetInfos.find(options.matchTarget);
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
@@ -388,6 +499,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
} else {
|
} else {
|
||||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
targetId = created.targetId;
|
targetId = created.targetId;
|
||||||
|
createdTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,5 +516,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
|||||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
return { sessionId, targetId };
|
return { sessionId, targetId, createdTarget };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user