mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
refactor(shared-skill-packages): add files allowlist support and filter test/changelog files
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ const PACKAGE_DEPENDENCY_SECTIONS = [
|
|||||||
"devDependencies",
|
"devDependencies",
|
||||||
];
|
];
|
||||||
|
|
||||||
const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules"]);
|
const SKIPPED_DIRS = new Set([".git", ".changeset", ".clawhub", ".clawdhub", "node_modules"]);
|
||||||
const SKIPPED_FILES = new Set([".DS_Store"]);
|
const SKIPPED_FILES = new Set([".DS_Store"]);
|
||||||
|
const TEST_DIR_NAMES = new Set(["__tests__", "test", "tests"]);
|
||||||
|
const TEST_FILE_PATTERN = /\.(test|spec)\.[^.]+$/;
|
||||||
|
const CHANGELOG_FILE_PATTERN = /^CHANGELOG(?:\..+)?\.md$/i;
|
||||||
|
const UNSUPPORTED_FILES_GLOB_PATTERN = /[*?[\]{}!]/;
|
||||||
|
|
||||||
export async function syncSharedSkillPackages(repoRoot, options = {}) {
|
export async function syncSharedSkillPackages(repoRoot, options = {}) {
|
||||||
const root = path.resolve(repoRoot);
|
const root = path.resolve(repoRoot);
|
||||||
@@ -131,23 +135,7 @@ async function syncPackageTree({ sourceDir, targetDir, workspacePackages }) {
|
|||||||
const sourcePackageJsonPath = path.join(sourceDir, "package.json");
|
const sourcePackageJsonPath = path.join(sourceDir, "package.json");
|
||||||
const packageJson = JSON.parse(await fs.readFile(sourcePackageJsonPath, "utf8"));
|
const packageJson = JSON.parse(await fs.readFile(sourcePackageJsonPath, "utf8"));
|
||||||
const localDeps = collectLocalDependencies(packageJson, workspacePackages);
|
const localDeps = collectLocalDependencies(packageJson, workspacePackages);
|
||||||
|
await copyPackageContents({ sourceDir, targetDir, packageJson });
|
||||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue;
|
|
||||||
|
|
||||||
const sourcePath = path.join(sourceDir, entry.name);
|
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await copyDirectory(sourcePath, targetPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.isFile() || entry.name === "package.json") continue;
|
|
||||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
await fs.copyFile(sourcePath, targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const name of localDeps) {
|
for (const name of localDeps) {
|
||||||
const nestedSourceDir = workspacePackages.get(name);
|
const nestedSourceDir = workspacePackages.get(name);
|
||||||
@@ -167,7 +155,7 @@ async function copyDirectory(sourceDir, targetDir) {
|
|||||||
await fs.mkdir(targetDir, { recursive: true });
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue;
|
if (shouldSkipEntry(entry)) continue;
|
||||||
|
|
||||||
const sourcePath = path.join(sourceDir, entry.name);
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
@@ -183,6 +171,102 @@ async function copyDirectory(sourceDir, targetDir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyPackageContents({ sourceDir, targetDir, packageJson }) {
|
||||||
|
const includedPaths = resolveIncludedPackagePaths(packageJson);
|
||||||
|
if (!includedPaths) {
|
||||||
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (shouldSkipEntry(entry)) continue;
|
||||||
|
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDirectory(sourcePath, targetPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile() || entry.name === "package.json") continue;
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.copyFile(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relativePath of includedPaths) {
|
||||||
|
const sourcePath = path.join(sourceDir, relativePath);
|
||||||
|
const targetPath = path.join(targetDir, relativePath);
|
||||||
|
await copyPath(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPath(sourcePath, targetPath) {
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.lstat(sourcePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = path.basename(sourcePath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (shouldSkipName(name, { isDirectory: true })) return;
|
||||||
|
await copyDirectory(sourcePath, targetPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stat.isFile()) return;
|
||||||
|
if (name === "package.json" || shouldSkipName(name, { isFile: true })) return;
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.copyFile(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIncludedPackagePaths(packageJson) {
|
||||||
|
if (!Array.isArray(packageJson.files)) return null;
|
||||||
|
|
||||||
|
const includedPaths = [];
|
||||||
|
for (const entry of packageJson.files) {
|
||||||
|
if (typeof entry !== "string") continue;
|
||||||
|
|
||||||
|
const normalized = normalizeIncludedPath(entry);
|
||||||
|
if (!normalized || normalized === "package.json") continue;
|
||||||
|
includedPaths.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(includedPaths)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncludedPath(entry) {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (UNSUPPORTED_FILES_GLOB_PATTERN.test(trimmed)) {
|
||||||
|
throw new Error(`Unsupported package.json files entry: ${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.posix.normalize(trimmed.replace(/\\/g, "/")).replace(/^(\.\/)+/, "");
|
||||||
|
if (!normalized || normalized === ".") return null;
|
||||||
|
if (path.posix.isAbsolute(normalized) || normalized.startsWith("../")) {
|
||||||
|
throw new Error(`Package file entry must stay within the package root: ${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipEntry(entry) {
|
||||||
|
return shouldSkipName(entry.name, {
|
||||||
|
isDirectory: entry.isDirectory(),
|
||||||
|
isFile: entry.isFile(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipName(name, { isDirectory = false, isFile = false } = {}) {
|
||||||
|
if (SKIPPED_DIRS.has(name) || SKIPPED_FILES.has(name)) return true;
|
||||||
|
if (isDirectory && TEST_DIR_NAMES.has(name)) return true;
|
||||||
|
if (isFile && (TEST_FILE_PATTERN.test(name) || CHANGELOG_FILE_PATTERN.test(name))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function discoverWorkspacePackages(repoRoot) {
|
async function discoverWorkspacePackages(repoRoot) {
|
||||||
const packagesRoot = path.join(repoRoot, "packages");
|
const packagesRoot = path.join(repoRoot, "packages");
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ test("syncSharedSkillPackages vendors workspace packages into skill scripts", as
|
|||||||
path.join(root, "packages", "baoyu-md", "src", "index.ts"),
|
path.join(root, "packages", "baoyu-md", "src", "index.ts"),
|
||||||
"export const markdown = true;\n",
|
"export const markdown = true;\n",
|
||||||
);
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "baoyu-md", "src", "index.test.ts"),
|
||||||
|
"test('ignored', () => {});\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "baoyu-md", "src", "__tests__", "helper.ts"),
|
||||||
|
"export const helper = true;\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "baoyu-md", "tests", "setup.ts"),
|
||||||
|
"export const setup = true;\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "baoyu-md", ".changeset", "demo.md"),
|
||||||
|
"---\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "baoyu-md", "CHANGELOG.md"),
|
||||||
|
"# changelog\n",
|
||||||
|
);
|
||||||
|
|
||||||
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
|
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
|
||||||
await writeJson(path.join(consumerDir, "package.json"), {
|
await writeJson(path.join(consumerDir, "package.json"), {
|
||||||
@@ -67,4 +87,97 @@ test("syncSharedSkillPackages vendors workspace packages into skill scripts", as
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(vendoredFile, /markdown = true/);
|
assert.match(vendoredFile, /markdown = true/);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "src", "index.test.ts"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "src", "__tests__", "helper.ts"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "tests", "setup.ts"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", ".changeset", "demo.md"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "CHANGELOG.md"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("syncSharedSkillPackages respects package.json files allowlists", async (t) => {
|
||||||
|
const root = await makeTempDir("baoyu-sync-files-");
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
await writeJson(path.join(root, "packages", "demo-pkg", "package.json"), {
|
||||||
|
name: "demo-pkg",
|
||||||
|
version: "1.0.0",
|
||||||
|
files: ["README.md", "src", "CHANGELOG.md", ".changeset"],
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "demo-pkg", "README.md"),
|
||||||
|
"# Demo\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "demo-pkg", "src", "index.ts"),
|
||||||
|
"export const demo = true;\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "demo-pkg", "src", "index.test.ts"),
|
||||||
|
"test('ignored', () => {});\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "demo-pkg", "docs", "private.md"),
|
||||||
|
"private\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "demo-pkg", "CHANGELOG.md"),
|
||||||
|
"# changelog\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "packages", "demo-pkg", ".changeset", "demo.md"),
|
||||||
|
"---\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
|
||||||
|
await writeJson(path.join(consumerDir, "package.json"), {
|
||||||
|
name: "demo-skill-scripts",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: {
|
||||||
|
"demo-pkg": "^1.0.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncSharedSkillPackages(root, { install: false });
|
||||||
|
|
||||||
|
const readme = await fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "README.md"), "utf8");
|
||||||
|
assert.match(readme, /Demo/);
|
||||||
|
|
||||||
|
const vendoredSource = await fs.readFile(
|
||||||
|
path.join(consumerDir, "vendor", "demo-pkg", "src", "index.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(vendoredSource, /demo = true/);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "docs", "private.md"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "CHANGELOG.md"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", ".changeset", "demo.md"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fs.readFile(path.join(consumerDir, "vendor", "demo-pkg", "src", "index.test.ts"), "utf8"),
|
||||||
|
{ code: "ENOENT" },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
-307
@@ -1,307 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
discoverRunningChromeDebugPort,
|
|
||||||
findChromeExecutable,
|
|
||||||
findExistingChromeDebugPort,
|
|
||||||
getFreePort,
|
|
||||||
openPageSession,
|
|
||||||
resolveSharedChromeProfileDir,
|
|
||||||
waitForChromeDebugPort,
|
|
||||||
} from "./index.ts";
|
|
||||||
|
|
||||||
function useEnv(
|
|
||||||
t: TestContext,
|
|
||||||
values: Record<string, string | null>,
|
|
||||||
): void {
|
|
||||||
const previous = new Map<string, string | undefined>();
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
previous.set(key, process.env[key]);
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
for (const [key, value] of previous.entries()) {
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDebugServer(port: number): Promise<http.Server> {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.url === "/json/version") {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeServer(server: http.Server): Promise<void> {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
|
||||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
|
||||||
|
|
||||||
const dynamicPort = await getFreePort();
|
|
||||||
assert.ok(Number.isInteger(dynamicPort));
|
|
||||||
assert.ok(dynamicPort > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const envChrome = path.join(root, "env-chrome");
|
|
||||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
|
||||||
await fs.writeFile(envChrome, "");
|
|
||||||
await fs.writeFile(fallbackChrome, "");
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
envChrome,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
fallbackChrome,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.resolve("/tmp/custom-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
wslWindowsHome: "/mnt/c/Users/demo",
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = resolveSharedChromeProfileDir({
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
});
|
|
||||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
|
||||||
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 findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
|
||||||
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) => {
|
|
||||||
const port = await getFreePort();
|
|
||||||
|
|
||||||
const serverPromise = (async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
const server = await startDebugServer(port);
|
|
||||||
t.after(() => closeServer(server));
|
|
||||||
})();
|
|
||||||
|
|
||||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
|
||||||
includeLastError: true,
|
|
||||||
});
|
|
||||||
await serverPromise;
|
|
||||||
|
|
||||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
|
||||||
});
|
|
||||||
+3
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
-307
@@ -1,307 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
discoverRunningChromeDebugPort,
|
|
||||||
findChromeExecutable,
|
|
||||||
findExistingChromeDebugPort,
|
|
||||||
getFreePort,
|
|
||||||
openPageSession,
|
|
||||||
resolveSharedChromeProfileDir,
|
|
||||||
waitForChromeDebugPort,
|
|
||||||
} from "./index.ts";
|
|
||||||
|
|
||||||
function useEnv(
|
|
||||||
t: TestContext,
|
|
||||||
values: Record<string, string | null>,
|
|
||||||
): void {
|
|
||||||
const previous = new Map<string, string | undefined>();
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
previous.set(key, process.env[key]);
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
for (const [key, value] of previous.entries()) {
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDebugServer(port: number): Promise<http.Server> {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.url === "/json/version") {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeServer(server: http.Server): Promise<void> {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
|
||||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
|
||||||
|
|
||||||
const dynamicPort = await getFreePort();
|
|
||||||
assert.ok(Number.isInteger(dynamicPort));
|
|
||||||
assert.ok(dynamicPort > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const envChrome = path.join(root, "env-chrome");
|
|
||||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
|
||||||
await fs.writeFile(envChrome, "");
|
|
||||||
await fs.writeFile(fallbackChrome, "");
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
envChrome,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
fallbackChrome,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.resolve("/tmp/custom-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
wslWindowsHome: "/mnt/c/Users/demo",
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = resolveSharedChromeProfileDir({
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
});
|
|
||||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
|
||||||
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 findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
|
||||||
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) => {
|
|
||||||
const port = await getFreePort();
|
|
||||||
|
|
||||||
const serverPromise = (async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
const server = await startDebugServer(port);
|
|
||||||
t.after(() => closeServer(server));
|
|
||||||
})();
|
|
||||||
|
|
||||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
|
||||||
includeLastError: true,
|
|
||||||
});
|
|
||||||
await serverPromise;
|
|
||||||
|
|
||||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
extractSummaryFromBody,
|
|
||||||
extractTitleFromMarkdown,
|
|
||||||
parseFrontmatter,
|
|
||||||
pickFirstString,
|
|
||||||
serializeFrontmatter,
|
|
||||||
stripWrappingQuotes,
|
|
||||||
toFrontmatterString,
|
|
||||||
} from "./content.ts";
|
|
||||||
|
|
||||||
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
|
|
||||||
const input = `---
|
|
||||||
title: "Hello World"
|
|
||||||
author: ‘Baoyu’
|
|
||||||
summary: plain text
|
|
||||||
---
|
|
||||||
# Heading
|
|
||||||
|
|
||||||
Body`;
|
|
||||||
|
|
||||||
const result = parseFrontmatter(input);
|
|
||||||
|
|
||||||
assert.deepEqual(result.frontmatter, {
|
|
||||||
title: "Hello World",
|
|
||||||
author: "Baoyu",
|
|
||||||
summary: "plain text",
|
|
||||||
});
|
|
||||||
assert.match(result.body, /^# Heading/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseFrontmatter returns original content when no frontmatter exists", () => {
|
|
||||||
const input = "# No frontmatter";
|
|
||||||
assert.deepEqual(parseFrontmatter(input), {
|
|
||||||
frontmatter: {},
|
|
||||||
body: input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializeFrontmatter renders YAML only when fields exist", () => {
|
|
||||||
assert.equal(serializeFrontmatter({}), "");
|
|
||||||
assert.equal(
|
|
||||||
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
|
|
||||||
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
|
|
||||||
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
|
|
||||||
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
|
|
||||||
assert.equal(stripWrappingQuotes("plain"), "plain");
|
|
||||||
|
|
||||||
assert.equal(toFrontmatterString("'hello'"), "hello");
|
|
||||||
assert.equal(toFrontmatterString(42), "42");
|
|
||||||
assert.equal(toFrontmatterString(false), "false");
|
|
||||||
assert.equal(toFrontmatterString({}), undefined);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
|
|
||||||
"123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
|
|
||||||
const markdown = `
|
|
||||||

|
|
||||||
## “My Title”
|
|
||||||
|
|
||||||
Body paragraph
|
|
||||||
`;
|
|
||||||
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
|
|
||||||
|
|
||||||
const summary = extractSummaryFromBody(
|
|
||||||
`
|
|
||||||
# Heading
|
|
||||||
> quote
|
|
||||||
- list
|
|
||||||
1. ordered
|
|
||||||
\`\`\`
|
|
||||||
code
|
|
||||||
\`\`\`
|
|
||||||
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
|
|
||||||
`,
|
|
||||||
70,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
summary,
|
|
||||||
"This is the first paragraph with a link and inline code that should...",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|
||||||
import {
|
|
||||||
buildMarkdownDocumentMeta,
|
|
||||||
formatTimestamp,
|
|
||||||
renderMarkdownDocument,
|
|
||||||
resolveColorToken,
|
|
||||||
resolveFontFamilyToken,
|
|
||||||
resolveMarkdownStyle,
|
|
||||||
resolveRenderOptions,
|
|
||||||
} from "./document.ts";
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInlineStyle(html: string, tagName: string, text: string): string {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
|
||||||
);
|
|
||||||
const match = html.match(pattern);
|
|
||||||
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
|
||||||
return match![1]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
|
||||||
const previous = process.cwd();
|
|
||||||
process.chdir(cwd);
|
|
||||||
t.after(() => {
|
|
||||||
process.chdir(previous);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("document token resolvers map known presets and allow passthrough values", () => {
|
|
||||||
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
|
|
||||||
assert.equal(resolveColorToken("#123456"), "#123456");
|
|
||||||
assert.equal(resolveColorToken(), undefined);
|
|
||||||
|
|
||||||
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
|
|
||||||
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
|
|
||||||
assert.equal(resolveFontFamilyToken(), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formatTimestamp uses compact sortable datetime output", () => {
|
|
||||||
const date = new Date("2026-03-13T21:04:05.000Z");
|
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
|
||||||
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
|
||||||
date.getDate(),
|
|
||||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
||||||
|
|
||||||
assert.equal(formatTimestamp(date), expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
|
|
||||||
const metaFromYaml = buildMarkdownDocumentMeta(
|
|
||||||
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
|
|
||||||
{
|
|
||||||
title: `" YAML Title "`,
|
|
||||||
author: "'Baoyu'",
|
|
||||||
summary: `" YAML Summary "`,
|
|
||||||
},
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(metaFromYaml, {
|
|
||||||
title: "YAML Title",
|
|
||||||
author: "Baoyu",
|
|
||||||
description: "YAML Summary",
|
|
||||||
});
|
|
||||||
|
|
||||||
const metaFromMarkdown = buildMarkdownDocumentMeta(
|
|
||||||
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
|
|
||||||
{},
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(metaFromMarkdown.title, "Markdown Title");
|
|
||||||
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
|
|
||||||
const style = resolveMarkdownStyle({
|
|
||||||
theme: "modern",
|
|
||||||
primaryColor: "#112233",
|
|
||||||
fontFamily: "Custom Sans",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(style.primaryColor, "#112233");
|
|
||||||
assert.equal(style.fontFamily, "Custom Sans");
|
|
||||||
assert.equal(style.fontSize, "15px");
|
|
||||||
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-md-render-options-");
|
|
||||||
useCwd(t, root);
|
|
||||||
|
|
||||||
const extendPath = path.join(
|
|
||||||
root,
|
|
||||||
".baoyu-skills",
|
|
||||||
"baoyu-markdown-to-html",
|
|
||||||
"EXTEND.md",
|
|
||||||
);
|
|
||||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
extendPath,
|
|
||||||
`---
|
|
||||||
default_theme: modern
|
|
||||||
default_color: green
|
|
||||||
default_font_family: mono
|
|
||||||
default_font_size: 17
|
|
||||||
default_code_theme: nord
|
|
||||||
mac_code_block: false
|
|
||||||
show_line_number: true
|
|
||||||
cite: true
|
|
||||||
count: true
|
|
||||||
legend: title-alt
|
|
||||||
keep_title: true
|
|
||||||
---
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fromExtend = resolveRenderOptions();
|
|
||||||
assert.equal(fromExtend.theme, "modern");
|
|
||||||
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
|
|
||||||
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
|
|
||||||
assert.equal(fromExtend.fontSize, "17px");
|
|
||||||
assert.equal(fromExtend.codeTheme, "nord");
|
|
||||||
assert.equal(fromExtend.isMacCodeBlock, false);
|
|
||||||
assert.equal(fromExtend.isShowLineNumber, true);
|
|
||||||
assert.equal(fromExtend.citeStatus, true);
|
|
||||||
assert.equal(fromExtend.countStatus, true);
|
|
||||||
assert.equal(fromExtend.legend, "title-alt");
|
|
||||||
assert.equal(fromExtend.keepTitle, true);
|
|
||||||
|
|
||||||
const explicit = resolveRenderOptions({
|
|
||||||
theme: "simple",
|
|
||||||
fontSize: "18px",
|
|
||||||
keepTitle: false,
|
|
||||||
});
|
|
||||||
assert.equal(explicit.theme, "simple");
|
|
||||||
assert.equal(explicit.fontSize, "18px");
|
|
||||||
assert.equal(explicit.keepTitle, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
|
||||||
const { html } = await renderMarkdownDocument(
|
|
||||||
`## Section\n\nParagraph with **bold** text.`,
|
|
||||||
{ keepTitle: true, theme: "grace" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const h2Style = findInlineStyle(html, "h2", "Section");
|
|
||||||
assert.match(h2Style, /background: #92617E/);
|
|
||||||
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
|
||||||
|
|
||||||
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
|
||||||
assert.ok(pMatch, "Expected inline style on <p> tag");
|
|
||||||
assert.match(pMatch![1]!, /color:/);
|
|
||||||
|
|
||||||
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
|
||||||
const strongMatch = html.match(strongPattern);
|
|
||||||
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
|
||||||
assert.match(strongMatch![1]!, /font-weight:/);
|
|
||||||
});
|
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { DEFAULT_STYLE } from "./constants.ts";
|
|
||||||
import {
|
|
||||||
buildCss,
|
|
||||||
buildHtmlDocument,
|
|
||||||
modifyHtmlStructure,
|
|
||||||
normalizeCssText,
|
|
||||||
normalizeInlineCss,
|
|
||||||
removeFirstHeading,
|
|
||||||
} from "./html-builder.ts";
|
|
||||||
|
|
||||||
test("buildCss injects style variables and concatenates base and theme CSS", () => {
|
|
||||||
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
|
|
||||||
|
|
||||||
assert.match(css, /--md-primary-color: #0F4C81;/);
|
|
||||||
assert.match(css, /body \{ color: red; \}/);
|
|
||||||
assert.match(css, /\.theme \{ color: blue; \}/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
|
||||||
const html = buildHtmlDocument(
|
|
||||||
{
|
|
||||||
title: "Doc",
|
|
||||||
author: "Baoyu",
|
|
||||||
description: "Summary",
|
|
||||||
},
|
|
||||||
"body { color: red; }",
|
|
||||||
"<article>Hello</article>",
|
|
||||||
".hljs { color: blue; }",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /<title>Doc<\/title>/);
|
|
||||||
assert.match(html, /meta name="author" content="Baoyu"/);
|
|
||||||
assert.match(html, /meta name="description" content="Summary"/);
|
|
||||||
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
|
|
||||||
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
|
|
||||||
assert.match(html, /<article>Hello<\/article>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
|
||||||
const rawCss = `
|
|
||||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
|
||||||
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
|
|
||||||
assert.match(normalizedCss, /color: #0F4C81/);
|
|
||||||
assert.match(normalizedCss, /font-size: 16px/);
|
|
||||||
assert.match(normalizedCss, /background: #3f3f3f/);
|
|
||||||
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
|
|
||||||
|
|
||||||
const normalizedHtml = normalizeInlineCss(
|
|
||||||
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
|
|
||||||
DEFAULT_STYLE,
|
|
||||||
);
|
|
||||||
assert.match(normalizedHtml, /color: #0F4C81/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
|
||||||
const normalizedHtml = normalizeInlineCss(
|
|
||||||
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
|
||||||
DEFAULT_STYLE,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
|
||||||
assert.equal(
|
|
||||||
modifyHtmlStructure(nestedList),
|
|
||||||
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
|
|
||||||
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getImageExtension,
|
|
||||||
replaceMarkdownImagesWithPlaceholders,
|
|
||||||
resolveContentImages,
|
|
||||||
resolveImagePath,
|
|
||||||
} from "./images.ts";
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
|
|
||||||
const result = replaceMarkdownImagesWithPlaceholders(
|
|
||||||
`\n\nText\n\n`,
|
|
||||||
"IMG_",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
|
|
||||||
assert.deepEqual(result.images, [
|
|
||||||
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
|
|
||||||
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
|
||||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
|
||||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
|
||||||
|
|
||||||
const root = await makeTempDir("baoyu-md-images-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const baseDir = path.join(root, "article");
|
|
||||||
const tempDir = path.join(root, "tmp");
|
|
||||||
await fs.mkdir(baseDir, { recursive: true });
|
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
|
||||||
|
|
||||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
|
||||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-md-content-images-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const baseDir = path.join(root, "article");
|
|
||||||
const tempDir = path.join(root, "tmp");
|
|
||||||
await fs.mkdir(baseDir, { recursive: true });
|
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
|
|
||||||
|
|
||||||
const resolved = await resolveContentImages(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
alt: "cover",
|
|
||||||
originalPath: "cover.png",
|
|
||||||
placeholder: "IMG_1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
baseDir,
|
|
||||||
tempDir,
|
|
||||||
"test",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(resolved, [
|
|
||||||
{
|
|
||||||
alt: "cover",
|
|
||||||
originalPath: "cover.png",
|
|
||||||
placeholder: "IMG_1",
|
|
||||||
localPath: path.join(baseDir, "cover.png"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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>/);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
-307
@@ -1,307 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
discoverRunningChromeDebugPort,
|
|
||||||
findChromeExecutable,
|
|
||||||
findExistingChromeDebugPort,
|
|
||||||
getFreePort,
|
|
||||||
openPageSession,
|
|
||||||
resolveSharedChromeProfileDir,
|
|
||||||
waitForChromeDebugPort,
|
|
||||||
} from "./index.ts";
|
|
||||||
|
|
||||||
function useEnv(
|
|
||||||
t: TestContext,
|
|
||||||
values: Record<string, string | null>,
|
|
||||||
): void {
|
|
||||||
const previous = new Map<string, string | undefined>();
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
previous.set(key, process.env[key]);
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
for (const [key, value] of previous.entries()) {
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDebugServer(port: number): Promise<http.Server> {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.url === "/json/version") {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeServer(server: http.Server): Promise<void> {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
|
||||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
|
||||||
|
|
||||||
const dynamicPort = await getFreePort();
|
|
||||||
assert.ok(Number.isInteger(dynamicPort));
|
|
||||||
assert.ok(dynamicPort > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const envChrome = path.join(root, "env-chrome");
|
|
||||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
|
||||||
await fs.writeFile(envChrome, "");
|
|
||||||
await fs.writeFile(fallbackChrome, "");
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
envChrome,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
fallbackChrome,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.resolve("/tmp/custom-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
wslWindowsHome: "/mnt/c/Users/demo",
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = resolveSharedChromeProfileDir({
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
});
|
|
||||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
|
||||||
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 findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
|
||||||
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) => {
|
|
||||||
const port = await getFreePort();
|
|
||||||
|
|
||||||
const serverPromise = (async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
const server = await startDebugServer(port);
|
|
||||||
t.after(() => closeServer(server));
|
|
||||||
})();
|
|
||||||
|
|
||||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
|
||||||
includeLastError: true,
|
|
||||||
});
|
|
||||||
await serverPromise;
|
|
||||||
|
|
||||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
extractSummaryFromBody,
|
|
||||||
extractTitleFromMarkdown,
|
|
||||||
parseFrontmatter,
|
|
||||||
pickFirstString,
|
|
||||||
serializeFrontmatter,
|
|
||||||
stripWrappingQuotes,
|
|
||||||
toFrontmatterString,
|
|
||||||
} from "./content.ts";
|
|
||||||
|
|
||||||
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
|
|
||||||
const input = `---
|
|
||||||
title: "Hello World"
|
|
||||||
author: ‘Baoyu’
|
|
||||||
summary: plain text
|
|
||||||
---
|
|
||||||
# Heading
|
|
||||||
|
|
||||||
Body`;
|
|
||||||
|
|
||||||
const result = parseFrontmatter(input);
|
|
||||||
|
|
||||||
assert.deepEqual(result.frontmatter, {
|
|
||||||
title: "Hello World",
|
|
||||||
author: "Baoyu",
|
|
||||||
summary: "plain text",
|
|
||||||
});
|
|
||||||
assert.match(result.body, /^# Heading/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseFrontmatter returns original content when no frontmatter exists", () => {
|
|
||||||
const input = "# No frontmatter";
|
|
||||||
assert.deepEqual(parseFrontmatter(input), {
|
|
||||||
frontmatter: {},
|
|
||||||
body: input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializeFrontmatter renders YAML only when fields exist", () => {
|
|
||||||
assert.equal(serializeFrontmatter({}), "");
|
|
||||||
assert.equal(
|
|
||||||
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
|
|
||||||
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
|
|
||||||
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
|
|
||||||
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
|
|
||||||
assert.equal(stripWrappingQuotes("plain"), "plain");
|
|
||||||
|
|
||||||
assert.equal(toFrontmatterString("'hello'"), "hello");
|
|
||||||
assert.equal(toFrontmatterString(42), "42");
|
|
||||||
assert.equal(toFrontmatterString(false), "false");
|
|
||||||
assert.equal(toFrontmatterString({}), undefined);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
|
|
||||||
"123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
|
|
||||||
const markdown = `
|
|
||||||

|
|
||||||
## “My Title”
|
|
||||||
|
|
||||||
Body paragraph
|
|
||||||
`;
|
|
||||||
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
|
|
||||||
|
|
||||||
const summary = extractSummaryFromBody(
|
|
||||||
`
|
|
||||||
# Heading
|
|
||||||
> quote
|
|
||||||
- list
|
|
||||||
1. ordered
|
|
||||||
\`\`\`
|
|
||||||
code
|
|
||||||
\`\`\`
|
|
||||||
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
|
|
||||||
`,
|
|
||||||
70,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
summary,
|
|
||||||
"This is the first paragraph with a link and inline code that should...",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|
||||||
import {
|
|
||||||
buildMarkdownDocumentMeta,
|
|
||||||
formatTimestamp,
|
|
||||||
renderMarkdownDocument,
|
|
||||||
resolveColorToken,
|
|
||||||
resolveFontFamilyToken,
|
|
||||||
resolveMarkdownStyle,
|
|
||||||
resolveRenderOptions,
|
|
||||||
} from "./document.ts";
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInlineStyle(html: string, tagName: string, text: string): string {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
|
||||||
);
|
|
||||||
const match = html.match(pattern);
|
|
||||||
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
|
||||||
return match![1]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
|
||||||
const previous = process.cwd();
|
|
||||||
process.chdir(cwd);
|
|
||||||
t.after(() => {
|
|
||||||
process.chdir(previous);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("document token resolvers map known presets and allow passthrough values", () => {
|
|
||||||
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
|
|
||||||
assert.equal(resolveColorToken("#123456"), "#123456");
|
|
||||||
assert.equal(resolveColorToken(), undefined);
|
|
||||||
|
|
||||||
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
|
|
||||||
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
|
|
||||||
assert.equal(resolveFontFamilyToken(), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formatTimestamp uses compact sortable datetime output", () => {
|
|
||||||
const date = new Date("2026-03-13T21:04:05.000Z");
|
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
|
||||||
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
|
||||||
date.getDate(),
|
|
||||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
||||||
|
|
||||||
assert.equal(formatTimestamp(date), expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
|
|
||||||
const metaFromYaml = buildMarkdownDocumentMeta(
|
|
||||||
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
|
|
||||||
{
|
|
||||||
title: `" YAML Title "`,
|
|
||||||
author: "'Baoyu'",
|
|
||||||
summary: `" YAML Summary "`,
|
|
||||||
},
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(metaFromYaml, {
|
|
||||||
title: "YAML Title",
|
|
||||||
author: "Baoyu",
|
|
||||||
description: "YAML Summary",
|
|
||||||
});
|
|
||||||
|
|
||||||
const metaFromMarkdown = buildMarkdownDocumentMeta(
|
|
||||||
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
|
|
||||||
{},
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(metaFromMarkdown.title, "Markdown Title");
|
|
||||||
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
|
|
||||||
const style = resolveMarkdownStyle({
|
|
||||||
theme: "modern",
|
|
||||||
primaryColor: "#112233",
|
|
||||||
fontFamily: "Custom Sans",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(style.primaryColor, "#112233");
|
|
||||||
assert.equal(style.fontFamily, "Custom Sans");
|
|
||||||
assert.equal(style.fontSize, "15px");
|
|
||||||
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-md-render-options-");
|
|
||||||
useCwd(t, root);
|
|
||||||
|
|
||||||
const extendPath = path.join(
|
|
||||||
root,
|
|
||||||
".baoyu-skills",
|
|
||||||
"baoyu-markdown-to-html",
|
|
||||||
"EXTEND.md",
|
|
||||||
);
|
|
||||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
extendPath,
|
|
||||||
`---
|
|
||||||
default_theme: modern
|
|
||||||
default_color: green
|
|
||||||
default_font_family: mono
|
|
||||||
default_font_size: 17
|
|
||||||
default_code_theme: nord
|
|
||||||
mac_code_block: false
|
|
||||||
show_line_number: true
|
|
||||||
cite: true
|
|
||||||
count: true
|
|
||||||
legend: title-alt
|
|
||||||
keep_title: true
|
|
||||||
---
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fromExtend = resolveRenderOptions();
|
|
||||||
assert.equal(fromExtend.theme, "modern");
|
|
||||||
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
|
|
||||||
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
|
|
||||||
assert.equal(fromExtend.fontSize, "17px");
|
|
||||||
assert.equal(fromExtend.codeTheme, "nord");
|
|
||||||
assert.equal(fromExtend.isMacCodeBlock, false);
|
|
||||||
assert.equal(fromExtend.isShowLineNumber, true);
|
|
||||||
assert.equal(fromExtend.citeStatus, true);
|
|
||||||
assert.equal(fromExtend.countStatus, true);
|
|
||||||
assert.equal(fromExtend.legend, "title-alt");
|
|
||||||
assert.equal(fromExtend.keepTitle, true);
|
|
||||||
|
|
||||||
const explicit = resolveRenderOptions({
|
|
||||||
theme: "simple",
|
|
||||||
fontSize: "18px",
|
|
||||||
keepTitle: false,
|
|
||||||
});
|
|
||||||
assert.equal(explicit.theme, "simple");
|
|
||||||
assert.equal(explicit.fontSize, "18px");
|
|
||||||
assert.equal(explicit.keepTitle, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
|
||||||
const { html } = await renderMarkdownDocument(
|
|
||||||
`## Section\n\nParagraph with **bold** text.`,
|
|
||||||
{ keepTitle: true, theme: "grace" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const h2Style = findInlineStyle(html, "h2", "Section");
|
|
||||||
assert.match(h2Style, /background: #92617E/);
|
|
||||||
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
|
||||||
|
|
||||||
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
|
||||||
assert.ok(pMatch, "Expected inline style on <p> tag");
|
|
||||||
assert.match(pMatch![1]!, /color:/);
|
|
||||||
|
|
||||||
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
|
||||||
const strongMatch = html.match(strongPattern);
|
|
||||||
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
|
||||||
assert.match(strongMatch![1]!, /font-weight:/);
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { DEFAULT_STYLE } from "./constants.ts";
|
|
||||||
import {
|
|
||||||
buildCss,
|
|
||||||
buildHtmlDocument,
|
|
||||||
modifyHtmlStructure,
|
|
||||||
normalizeCssText,
|
|
||||||
normalizeInlineCss,
|
|
||||||
removeFirstHeading,
|
|
||||||
} from "./html-builder.ts";
|
|
||||||
|
|
||||||
test("buildCss injects style variables and concatenates base and theme CSS", () => {
|
|
||||||
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
|
|
||||||
|
|
||||||
assert.match(css, /--md-primary-color: #0F4C81;/);
|
|
||||||
assert.match(css, /body \{ color: red; \}/);
|
|
||||||
assert.match(css, /\.theme \{ color: blue; \}/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
|
||||||
const html = buildHtmlDocument(
|
|
||||||
{
|
|
||||||
title: "Doc",
|
|
||||||
author: "Baoyu",
|
|
||||||
description: "Summary",
|
|
||||||
},
|
|
||||||
"body { color: red; }",
|
|
||||||
"<article>Hello</article>",
|
|
||||||
".hljs { color: blue; }",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /<title>Doc<\/title>/);
|
|
||||||
assert.match(html, /meta name="author" content="Baoyu"/);
|
|
||||||
assert.match(html, /meta name="description" content="Summary"/);
|
|
||||||
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
|
|
||||||
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
|
|
||||||
assert.match(html, /<article>Hello<\/article>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
|
||||||
const rawCss = `
|
|
||||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
|
||||||
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
|
|
||||||
assert.match(normalizedCss, /color: #0F4C81/);
|
|
||||||
assert.match(normalizedCss, /font-size: 16px/);
|
|
||||||
assert.match(normalizedCss, /background: #3f3f3f/);
|
|
||||||
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
|
|
||||||
|
|
||||||
const normalizedHtml = normalizeInlineCss(
|
|
||||||
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
|
|
||||||
DEFAULT_STYLE,
|
|
||||||
);
|
|
||||||
assert.match(normalizedHtml, /color: #0F4C81/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
|
||||||
const normalizedHtml = normalizeInlineCss(
|
|
||||||
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
|
||||||
DEFAULT_STYLE,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
|
||||||
assert.equal(
|
|
||||||
modifyHtmlStructure(nestedList),
|
|
||||||
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
|
|
||||||
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getImageExtension,
|
|
||||||
replaceMarkdownImagesWithPlaceholders,
|
|
||||||
resolveContentImages,
|
|
||||||
resolveImagePath,
|
|
||||||
} from "./images.ts";
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
|
|
||||||
const result = replaceMarkdownImagesWithPlaceholders(
|
|
||||||
`\n\nText\n\n`,
|
|
||||||
"IMG_",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
|
|
||||||
assert.deepEqual(result.images, [
|
|
||||||
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
|
|
||||||
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
|
||||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
|
||||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
|
||||||
|
|
||||||
const root = await makeTempDir("baoyu-md-images-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const baseDir = path.join(root, "article");
|
|
||||||
const tempDir = path.join(root, "tmp");
|
|
||||||
await fs.mkdir(baseDir, { recursive: true });
|
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
|
||||||
|
|
||||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
|
||||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-md-content-images-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const baseDir = path.join(root, "article");
|
|
||||||
const tempDir = path.join(root, "tmp");
|
|
||||||
await fs.mkdir(baseDir, { recursive: true });
|
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
|
|
||||||
|
|
||||||
const resolved = await resolveContentImages(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
alt: "cover",
|
|
||||||
originalPath: "cover.png",
|
|
||||||
placeholder: "IMG_1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
baseDir,
|
|
||||||
tempDir,
|
|
||||||
"test",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(resolved, [
|
|
||||||
{
|
|
||||||
alt: "cover",
|
|
||||||
originalPath: "cover.png",
|
|
||||||
placeholder: "IMG_1",
|
|
||||||
localPath: path.join(baseDir, "cover.png"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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>/);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
-307
@@ -1,307 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
discoverRunningChromeDebugPort,
|
|
||||||
findChromeExecutable,
|
|
||||||
findExistingChromeDebugPort,
|
|
||||||
getFreePort,
|
|
||||||
openPageSession,
|
|
||||||
resolveSharedChromeProfileDir,
|
|
||||||
waitForChromeDebugPort,
|
|
||||||
} from "./index.ts";
|
|
||||||
|
|
||||||
function useEnv(
|
|
||||||
t: TestContext,
|
|
||||||
values: Record<string, string | null>,
|
|
||||||
): void {
|
|
||||||
const previous = new Map<string, string | undefined>();
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
previous.set(key, process.env[key]);
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
for (const [key, value] of previous.entries()) {
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDebugServer(port: number): Promise<http.Server> {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.url === "/json/version") {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeServer(server: http.Server): Promise<void> {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
|
||||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
|
||||||
|
|
||||||
const dynamicPort = await getFreePort();
|
|
||||||
assert.ok(Number.isInteger(dynamicPort));
|
|
||||||
assert.ok(dynamicPort > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const envChrome = path.join(root, "env-chrome");
|
|
||||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
|
||||||
await fs.writeFile(envChrome, "");
|
|
||||||
await fs.writeFile(fallbackChrome, "");
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
envChrome,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
fallbackChrome,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.resolve("/tmp/custom-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
wslWindowsHome: "/mnt/c/Users/demo",
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = resolveSharedChromeProfileDir({
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
});
|
|
||||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
|
||||||
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 findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
|
||||||
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) => {
|
|
||||||
const port = await getFreePort();
|
|
||||||
|
|
||||||
const serverPromise = (async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
const server = await startDebugServer(port);
|
|
||||||
t.after(() => closeServer(server));
|
|
||||||
})();
|
|
||||||
|
|
||||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
|
||||||
includeLastError: true,
|
|
||||||
});
|
|
||||||
await serverPromise;
|
|
||||||
|
|
||||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
extractSummaryFromBody,
|
|
||||||
extractTitleFromMarkdown,
|
|
||||||
parseFrontmatter,
|
|
||||||
pickFirstString,
|
|
||||||
serializeFrontmatter,
|
|
||||||
stripWrappingQuotes,
|
|
||||||
toFrontmatterString,
|
|
||||||
} from "./content.ts";
|
|
||||||
|
|
||||||
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
|
|
||||||
const input = `---
|
|
||||||
title: "Hello World"
|
|
||||||
author: ‘Baoyu’
|
|
||||||
summary: plain text
|
|
||||||
---
|
|
||||||
# Heading
|
|
||||||
|
|
||||||
Body`;
|
|
||||||
|
|
||||||
const result = parseFrontmatter(input);
|
|
||||||
|
|
||||||
assert.deepEqual(result.frontmatter, {
|
|
||||||
title: "Hello World",
|
|
||||||
author: "Baoyu",
|
|
||||||
summary: "plain text",
|
|
||||||
});
|
|
||||||
assert.match(result.body, /^# Heading/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseFrontmatter returns original content when no frontmatter exists", () => {
|
|
||||||
const input = "# No frontmatter";
|
|
||||||
assert.deepEqual(parseFrontmatter(input), {
|
|
||||||
frontmatter: {},
|
|
||||||
body: input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializeFrontmatter renders YAML only when fields exist", () => {
|
|
||||||
assert.equal(serializeFrontmatter({}), "");
|
|
||||||
assert.equal(
|
|
||||||
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
|
|
||||||
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
|
|
||||||
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
|
|
||||||
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
|
|
||||||
assert.equal(stripWrappingQuotes("plain"), "plain");
|
|
||||||
|
|
||||||
assert.equal(toFrontmatterString("'hello'"), "hello");
|
|
||||||
assert.equal(toFrontmatterString(42), "42");
|
|
||||||
assert.equal(toFrontmatterString(false), "false");
|
|
||||||
assert.equal(toFrontmatterString({}), undefined);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
|
|
||||||
"123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
|
|
||||||
const markdown = `
|
|
||||||

|
|
||||||
## “My Title”
|
|
||||||
|
|
||||||
Body paragraph
|
|
||||||
`;
|
|
||||||
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
|
|
||||||
|
|
||||||
const summary = extractSummaryFromBody(
|
|
||||||
`
|
|
||||||
# Heading
|
|
||||||
> quote
|
|
||||||
- list
|
|
||||||
1. ordered
|
|
||||||
\`\`\`
|
|
||||||
code
|
|
||||||
\`\`\`
|
|
||||||
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
|
|
||||||
`,
|
|
||||||
70,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
summary,
|
|
||||||
"This is the first paragraph with a link and inline code that should...",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|
||||||
import {
|
|
||||||
buildMarkdownDocumentMeta,
|
|
||||||
formatTimestamp,
|
|
||||||
renderMarkdownDocument,
|
|
||||||
resolveColorToken,
|
|
||||||
resolveFontFamilyToken,
|
|
||||||
resolveMarkdownStyle,
|
|
||||||
resolveRenderOptions,
|
|
||||||
} from "./document.ts";
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInlineStyle(html: string, tagName: string, text: string): string {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
|
||||||
);
|
|
||||||
const match = html.match(pattern);
|
|
||||||
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
|
||||||
return match![1]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
|
||||||
const previous = process.cwd();
|
|
||||||
process.chdir(cwd);
|
|
||||||
t.after(() => {
|
|
||||||
process.chdir(previous);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("document token resolvers map known presets and allow passthrough values", () => {
|
|
||||||
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
|
|
||||||
assert.equal(resolveColorToken("#123456"), "#123456");
|
|
||||||
assert.equal(resolveColorToken(), undefined);
|
|
||||||
|
|
||||||
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
|
|
||||||
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
|
|
||||||
assert.equal(resolveFontFamilyToken(), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formatTimestamp uses compact sortable datetime output", () => {
|
|
||||||
const date = new Date("2026-03-13T21:04:05.000Z");
|
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
|
||||||
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
|
||||||
date.getDate(),
|
|
||||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
||||||
|
|
||||||
assert.equal(formatTimestamp(date), expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
|
|
||||||
const metaFromYaml = buildMarkdownDocumentMeta(
|
|
||||||
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
|
|
||||||
{
|
|
||||||
title: `" YAML Title "`,
|
|
||||||
author: "'Baoyu'",
|
|
||||||
summary: `" YAML Summary "`,
|
|
||||||
},
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(metaFromYaml, {
|
|
||||||
title: "YAML Title",
|
|
||||||
author: "Baoyu",
|
|
||||||
description: "YAML Summary",
|
|
||||||
});
|
|
||||||
|
|
||||||
const metaFromMarkdown = buildMarkdownDocumentMeta(
|
|
||||||
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
|
|
||||||
{},
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(metaFromMarkdown.title, "Markdown Title");
|
|
||||||
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
|
|
||||||
const style = resolveMarkdownStyle({
|
|
||||||
theme: "modern",
|
|
||||||
primaryColor: "#112233",
|
|
||||||
fontFamily: "Custom Sans",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(style.primaryColor, "#112233");
|
|
||||||
assert.equal(style.fontFamily, "Custom Sans");
|
|
||||||
assert.equal(style.fontSize, "15px");
|
|
||||||
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-md-render-options-");
|
|
||||||
useCwd(t, root);
|
|
||||||
|
|
||||||
const extendPath = path.join(
|
|
||||||
root,
|
|
||||||
".baoyu-skills",
|
|
||||||
"baoyu-markdown-to-html",
|
|
||||||
"EXTEND.md",
|
|
||||||
);
|
|
||||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
extendPath,
|
|
||||||
`---
|
|
||||||
default_theme: modern
|
|
||||||
default_color: green
|
|
||||||
default_font_family: mono
|
|
||||||
default_font_size: 17
|
|
||||||
default_code_theme: nord
|
|
||||||
mac_code_block: false
|
|
||||||
show_line_number: true
|
|
||||||
cite: true
|
|
||||||
count: true
|
|
||||||
legend: title-alt
|
|
||||||
keep_title: true
|
|
||||||
---
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fromExtend = resolveRenderOptions();
|
|
||||||
assert.equal(fromExtend.theme, "modern");
|
|
||||||
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
|
|
||||||
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
|
|
||||||
assert.equal(fromExtend.fontSize, "17px");
|
|
||||||
assert.equal(fromExtend.codeTheme, "nord");
|
|
||||||
assert.equal(fromExtend.isMacCodeBlock, false);
|
|
||||||
assert.equal(fromExtend.isShowLineNumber, true);
|
|
||||||
assert.equal(fromExtend.citeStatus, true);
|
|
||||||
assert.equal(fromExtend.countStatus, true);
|
|
||||||
assert.equal(fromExtend.legend, "title-alt");
|
|
||||||
assert.equal(fromExtend.keepTitle, true);
|
|
||||||
|
|
||||||
const explicit = resolveRenderOptions({
|
|
||||||
theme: "simple",
|
|
||||||
fontSize: "18px",
|
|
||||||
keepTitle: false,
|
|
||||||
});
|
|
||||||
assert.equal(explicit.theme, "simple");
|
|
||||||
assert.equal(explicit.fontSize, "18px");
|
|
||||||
assert.equal(explicit.keepTitle, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
|
||||||
const { html } = await renderMarkdownDocument(
|
|
||||||
`## Section\n\nParagraph with **bold** text.`,
|
|
||||||
{ keepTitle: true, theme: "grace" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const h2Style = findInlineStyle(html, "h2", "Section");
|
|
||||||
assert.match(h2Style, /background: #92617E/);
|
|
||||||
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
|
||||||
|
|
||||||
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
|
||||||
assert.ok(pMatch, "Expected inline style on <p> tag");
|
|
||||||
assert.match(pMatch![1]!, /color:/);
|
|
||||||
|
|
||||||
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
|
||||||
const strongMatch = html.match(strongPattern);
|
|
||||||
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
|
||||||
assert.match(strongMatch![1]!, /font-weight:/);
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { DEFAULT_STYLE } from "./constants.ts";
|
|
||||||
import {
|
|
||||||
buildCss,
|
|
||||||
buildHtmlDocument,
|
|
||||||
modifyHtmlStructure,
|
|
||||||
normalizeCssText,
|
|
||||||
normalizeInlineCss,
|
|
||||||
removeFirstHeading,
|
|
||||||
} from "./html-builder.ts";
|
|
||||||
|
|
||||||
test("buildCss injects style variables and concatenates base and theme CSS", () => {
|
|
||||||
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
|
|
||||||
|
|
||||||
assert.match(css, /--md-primary-color: #0F4C81;/);
|
|
||||||
assert.match(css, /body \{ color: red; \}/);
|
|
||||||
assert.match(css, /\.theme \{ color: blue; \}/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
|
||||||
const html = buildHtmlDocument(
|
|
||||||
{
|
|
||||||
title: "Doc",
|
|
||||||
author: "Baoyu",
|
|
||||||
description: "Summary",
|
|
||||||
},
|
|
||||||
"body { color: red; }",
|
|
||||||
"<article>Hello</article>",
|
|
||||||
".hljs { color: blue; }",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /<title>Doc<\/title>/);
|
|
||||||
assert.match(html, /meta name="author" content="Baoyu"/);
|
|
||||||
assert.match(html, /meta name="description" content="Summary"/);
|
|
||||||
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
|
|
||||||
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
|
|
||||||
assert.match(html, /<article>Hello<\/article>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
|
||||||
const rawCss = `
|
|
||||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
|
||||||
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
|
|
||||||
assert.match(normalizedCss, /color: #0F4C81/);
|
|
||||||
assert.match(normalizedCss, /font-size: 16px/);
|
|
||||||
assert.match(normalizedCss, /background: #3f3f3f/);
|
|
||||||
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
|
|
||||||
|
|
||||||
const normalizedHtml = normalizeInlineCss(
|
|
||||||
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
|
|
||||||
DEFAULT_STYLE,
|
|
||||||
);
|
|
||||||
assert.match(normalizedHtml, /color: #0F4C81/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
|
||||||
const normalizedHtml = normalizeInlineCss(
|
|
||||||
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
|
||||||
DEFAULT_STYLE,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
|
||||||
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
|
||||||
assert.equal(
|
|
||||||
modifyHtmlStructure(nestedList),
|
|
||||||
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
|
|
||||||
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getImageExtension,
|
|
||||||
replaceMarkdownImagesWithPlaceholders,
|
|
||||||
resolveContentImages,
|
|
||||||
resolveImagePath,
|
|
||||||
} from "./images.ts";
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
|
|
||||||
const result = replaceMarkdownImagesWithPlaceholders(
|
|
||||||
`\n\nText\n\n`,
|
|
||||||
"IMG_",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
|
|
||||||
assert.deepEqual(result.images, [
|
|
||||||
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
|
|
||||||
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
|
||||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
|
||||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
|
||||||
|
|
||||||
const root = await makeTempDir("baoyu-md-images-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const baseDir = path.join(root, "article");
|
|
||||||
const tempDir = path.join(root, "tmp");
|
|
||||||
await fs.mkdir(baseDir, { recursive: true });
|
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
|
||||||
|
|
||||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
|
||||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-md-content-images-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const baseDir = path.join(root, "article");
|
|
||||||
const tempDir = path.join(root, "tmp");
|
|
||||||
await fs.mkdir(baseDir, { recursive: true });
|
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
|
|
||||||
|
|
||||||
const resolved = await resolveContentImages(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
alt: "cover",
|
|
||||||
originalPath: "cover.png",
|
|
||||||
placeholder: "IMG_1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
baseDir,
|
|
||||||
tempDir,
|
|
||||||
"test",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(resolved, [
|
|
||||||
{
|
|
||||||
alt: "cover",
|
|
||||||
originalPath: "cover.png",
|
|
||||||
placeholder: "IMG_1",
|
|
||||||
localPath: path.join(baseDir, "cover.png"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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>/);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import test, { type TestContext } from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
discoverRunningChromeDebugPort,
|
|
||||||
findChromeExecutable,
|
|
||||||
findExistingChromeDebugPort,
|
|
||||||
getFreePort,
|
|
||||||
openPageSession,
|
|
||||||
resolveSharedChromeProfileDir,
|
|
||||||
waitForChromeDebugPort,
|
|
||||||
} from "./index.ts";
|
|
||||||
|
|
||||||
function useEnv(
|
|
||||||
t: TestContext,
|
|
||||||
values: Record<string, string | null>,
|
|
||||||
): void {
|
|
||||||
const previous = new Map<string, string | undefined>();
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
previous.set(key, process.env[key]);
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
for (const [key, value] of previous.entries()) {
|
|
||||||
if (value == null) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir(prefix: string): Promise<string> {
|
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDebugServer(port: number): Promise<http.Server> {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.url === "/json/version") {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeServer(server: http.Server): Promise<void> {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
|
||||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
|
||||||
|
|
||||||
const dynamicPort = await getFreePort();
|
|
||||||
assert.ok(Number.isInteger(dynamicPort));
|
|
||||||
assert.ok(dynamicPort > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const envChrome = path.join(root, "env-chrome");
|
|
||||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
|
||||||
await fs.writeFile(envChrome, "");
|
|
||||||
await fs.writeFile(fallbackChrome, "");
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
envChrome,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
|
||||||
assert.equal(
|
|
||||||
findChromeExecutable({
|
|
||||||
envNames: ["BAOYU_CHROME_PATH"],
|
|
||||||
candidates: { default: [fallbackChrome] },
|
|
||||||
}),
|
|
||||||
fallbackChrome,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.resolve("/tmp/custom-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
|
||||||
assert.equal(
|
|
||||||
resolveSharedChromeProfileDir({
|
|
||||||
wslWindowsHome: "/mnt/c/Users/demo",
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
}),
|
|
||||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = resolveSharedChromeProfileDir({
|
|
||||||
appDataDirName: "demo-app",
|
|
||||||
profileDirName: "demo-profile",
|
|
||||||
});
|
|
||||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
|
||||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
|
||||||
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 findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
|
||||||
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) => {
|
|
||||||
const port = await getFreePort();
|
|
||||||
|
|
||||||
const serverPromise = (async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
const server = await startDebugServer(port);
|
|
||||||
t.after(() => closeServer(server));
|
|
||||||
})();
|
|
||||||
|
|
||||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
|
||||||
includeLastError: true,
|
|
||||||
});
|
|
||||||
await serverPromise;
|
|
||||||
|
|
||||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user