mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
||||
@@ -10,8 +10,12 @@ const PACKAGE_DEPENDENCY_SECTIONS = [
|
||||
"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 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 = {}) {
|
||||
const root = path.resolve(repoRoot);
|
||||
@@ -131,23 +135,7 @@ async function syncPackageTree({ sourceDir, targetDir, workspacePackages }) {
|
||||
const sourcePackageJsonPath = path.join(sourceDir, "package.json");
|
||||
const packageJson = JSON.parse(await fs.readFile(sourcePackageJsonPath, "utf8"));
|
||||
const localDeps = collectLocalDependencies(packageJson, workspacePackages);
|
||||
|
||||
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);
|
||||
}
|
||||
await copyPackageContents({ sourceDir, targetDir, packageJson });
|
||||
|
||||
for (const name of localDeps) {
|
||||
const nestedSourceDir = workspacePackages.get(name);
|
||||
@@ -167,7 +155,7 @@ async function copyDirectory(sourceDir, targetDir) {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
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;
|
||||
if (shouldSkipEntry(entry)) continue;
|
||||
|
||||
const sourcePath = path.join(sourceDir, 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) {
|
||||
const packagesRoot = path.join(repoRoot, "packages");
|
||||
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"),
|
||||
"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");
|
||||
await writeJson(path.join(consumerDir, "package.json"), {
|
||||
@@ -67,4 +87,97 @@ test("syncSharedSkillPackages vendors workspace packages into skill scripts", as
|
||||
"utf8",
|
||||
);
|
||||
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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./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