mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d2b95d1d1 | |||
| ac2ce0b8b6 | |||
| de7dc85361 | |||
| 0c02b81885 | |||
| 774ad784d8 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.66.0"
|
"version": "1.67.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.67.0 - 2026-03-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add qwen-image-2.0-pro model support for DashScope provider with free-form sizes and text rendering (by @JianJang2017)
|
||||||
|
|
||||||
|
## 1.66.1 - 2026-03-13
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Migrate test files from centralized `tests/` directory to colocate with source code
|
||||||
|
- Convert tests from `.mjs` to TypeScript (`.test.ts`) with `tsx` runner
|
||||||
|
- Add npm workspaces configuration and npm cache to CI workflow
|
||||||
|
|
||||||
## 1.66.0 - 2026-03-13
|
## 1.66.0 - 2026-03-13
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.67.0 - 2026-03-13
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增 DashScope qwen-image-2.0-pro 模型支持,支持自由尺寸和文字渲染 (by @JianJang2017)
|
||||||
|
|
||||||
|
## 1.66.1 - 2026-03-13
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
- 将测试文件从集中式 `tests/` 目录迁移至与源码同级
|
||||||
|
- 将测试从 `.mjs` 转换为 TypeScript(`.test.ts`),使用 `tsx` 运行器
|
||||||
|
- 新增 npm workspaces 配置,CI 工作流添加 npm 缓存
|
||||||
|
|
||||||
## 1.66.0 - 2026-03-13
|
## 1.66.0 - 2026-03-13
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.66.0**.
|
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.67.0**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
|||||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||||
@@ -996,7 +996,7 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
|||||||
|
|
||||||
# DashScope (Aliyun Tongyi Wanxiang)
|
# DashScope (Aliyun Tongyi Wanxiang)
|
||||||
DASHSCOPE_API_KEY=sk-xxx
|
DASHSCOPE_API_KEY=sk-xxx
|
||||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
|
|
||||||
# Replicate
|
# Replicate
|
||||||
|
|||||||
+2
-2
@@ -726,7 +726,7 @@ AI 驱动的生成后端。
|
|||||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||||
@@ -996,7 +996,7 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
|||||||
|
|
||||||
# DashScope(阿里通义万相)
|
# DashScope(阿里通义万相)
|
||||||
DASHSCOPE_API_KEY=sk-xxx
|
DASHSCOPE_API_KEY=sk-xxx
|
||||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
|
|
||||||
# Replicate
|
# Replicate
|
||||||
|
|||||||
Generated
+1958
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -2,8 +2,14 @@
|
|||||||
"name": "baoyu-skills",
|
"name": "baoyu-skills",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test",
|
"test": "node --import tsx --test",
|
||||||
"test:coverage": "node --experimental-test-coverage --test"
|
"test:coverage": "node --import tsx --experimental-test-coverage --test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.20.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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...",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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,
|
||||||
|
resolveColorToken,
|
||||||
|
resolveFontFamilyToken,
|
||||||
|
resolveMarkdownStyle,
|
||||||
|
resolveRenderOptions,
|
||||||
|
} from "./document.ts";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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("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>`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -7,18 +7,18 @@ import test from "node:test";
|
|||||||
import {
|
import {
|
||||||
listReleaseFiles,
|
listReleaseFiles,
|
||||||
validateSelfContainedRelease,
|
validateSelfContainedRelease,
|
||||||
} from "../scripts/lib/release-files.mjs";
|
} from "./release-files.mjs";
|
||||||
|
|
||||||
async function makeTempDir(prefix) {
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeFile(filePath, contents = "") {
|
async function writeFile(filePath: string, contents = ""): Promise<void> {
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.writeFile(filePath, contents);
|
await fs.writeFile(filePath, contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeJson(filePath, value) {
|
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,18 +4,18 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { syncSharedSkillPackages } from "../scripts/lib/shared-skill-packages.mjs";
|
import { syncSharedSkillPackages } from "./shared-skill-packages.mjs";
|
||||||
|
|
||||||
async function makeTempDir(prefix) {
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeFile(filePath, contents = "") {
|
async function writeFile(filePath: string, contents = ""): Promise<void> {
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.writeFile(filePath, contents);
|
await fs.writeFile(filePath, contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeJson(filePath, value) {
|
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +53,13 @@ test("syncSharedSkillPackages vendors workspace packages into skill scripts", as
|
|||||||
|
|
||||||
const updatedPackageJson = JSON.parse(
|
const updatedPackageJson = JSON.parse(
|
||||||
await fs.readFile(path.join(consumerDir, "package.json"), "utf8"),
|
await fs.readFile(path.join(consumerDir, "package.json"), "utf8"),
|
||||||
);
|
) as { dependencies: Record<string, string> };
|
||||||
assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md");
|
assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md");
|
||||||
assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5");
|
assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5");
|
||||||
|
|
||||||
const vendoredPackageJson = JSON.parse(
|
const vendoredPackageJson = JSON.parse(
|
||||||
await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"),
|
await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"),
|
||||||
);
|
) as { name: string };
|
||||||
assert.equal(vendoredPackageJson.name, "baoyu-md");
|
assert.equal(vendoredPackageJson.name, "baoyu-md");
|
||||||
|
|
||||||
const vendoredFile = await fs.readFile(
|
const vendoredFile = await fs.readFile(
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
@@ -92,6 +92,12 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider o
|
|||||||
# DashScope (阿里通义万象)
|
# DashScope (阿里通义万象)
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||||
|
|
||||||
|
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||||
|
|
||||||
|
# DashScope legacy Qwen fixed-size model
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
||||||
|
|
||||||
# Replicate (google/nano-banana-pro)
|
# Replicate (google/nano-banana-pro)
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||||
|
|
||||||
@@ -142,7 +148,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
|||||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||||
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||||
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`) |
|
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) |
|
||||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||||
@@ -166,7 +172,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
|||||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||||
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||||
@@ -201,6 +207,52 @@ Model priority (highest → lowest), applies to all providers:
|
|||||||
- Show: `Using [provider] / [model]`
|
- Show: `Using [provider] / [model]`
|
||||||
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
||||||
|
|
||||||
|
### DashScope Models
|
||||||
|
|
||||||
|
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
|
||||||
|
|
||||||
|
Official DashScope model families:
|
||||||
|
|
||||||
|
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
|
||||||
|
- Free-form `size` in `宽*高` format
|
||||||
|
- Total pixels must stay between `512*512` and `2048*2048`
|
||||||
|
- Default size is approximately `1024*1024`
|
||||||
|
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
|
||||||
|
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
|
||||||
|
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
|
||||||
|
- Default size is `1664*928`
|
||||||
|
- `qwen-image` currently has the same capability as `qwen-image-plus`
|
||||||
|
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
|
||||||
|
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
|
||||||
|
|
||||||
|
When translating CLI args into DashScope behavior:
|
||||||
|
|
||||||
|
- `--size` wins over `--ar`
|
||||||
|
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
|
||||||
|
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
|
||||||
|
- `--quality` is a baoyu-image-gen compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
|
||||||
|
|
||||||
|
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
||||||
|
|
||||||
|
| Ratio | `normal` | `2k` |
|
||||||
|
|-------|----------|------|
|
||||||
|
| `1:1` | `1024*1024` | `1536*1536` |
|
||||||
|
| `2:3` | `768*1152` | `1024*1536` |
|
||||||
|
| `3:2` | `1152*768` | `1536*1024` |
|
||||||
|
| `3:4` | `960*1280` | `1080*1440` |
|
||||||
|
| `4:3` | `1280*960` | `1440*1080` |
|
||||||
|
| `9:16` | `720*1280` | `1080*1920` |
|
||||||
|
| `16:9` | `1280*720` | `1920*1080` |
|
||||||
|
| `21:9` | `1344*576` | `2048*872` |
|
||||||
|
|
||||||
|
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-image-gen` does not expose them as dedicated CLI flags today.
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
|
||||||
|
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
||||||
|
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
||||||
|
|
||||||
### OpenRouter Models
|
### OpenRouter Models
|
||||||
|
|
||||||
Use full OpenRouter model IDs, e.g.:
|
Use full OpenRouter model IDs, e.g.:
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ options:
|
|||||||
- label: "OpenRouter"
|
- label: "OpenRouter"
|
||||||
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
||||||
- label: "DashScope"
|
- label: "DashScope"
|
||||||
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
|
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
||||||
- label: "Replicate"
|
- label: "Replicate"
|
||||||
description: "Community models - nano-banana-pro, flexible model selection"
|
description: "Community models - nano-banana-pro, flexible model selection"
|
||||||
```
|
```
|
||||||
@@ -186,12 +186,26 @@ options:
|
|||||||
header: "DashScope Model"
|
header: "DashScope Model"
|
||||||
question: "Choose a default DashScope image generation model?"
|
question: "Choose a default DashScope image generation model?"
|
||||||
options:
|
options:
|
||||||
- label: "z-image-turbo (Recommended)"
|
- label: "qwen-image-2.0-pro (Recommended)"
|
||||||
description: "Fast generation, good quality"
|
description: "Best DashScope model for text rendering and custom sizes"
|
||||||
|
- label: "qwen-image-2.0"
|
||||||
|
description: "Faster 2.0 variant with flexible output size"
|
||||||
|
- label: "qwen-image-max"
|
||||||
|
description: "Legacy Qwen model with five fixed output sizes"
|
||||||
|
- label: "qwen-image-plus"
|
||||||
|
description: "Legacy Qwen model, same current capability as qwen-image"
|
||||||
|
- label: "z-image-turbo"
|
||||||
|
description: "Legacy DashScope model for compatibility"
|
||||||
- label: "z-image-ultra"
|
- label: "z-image-ultra"
|
||||||
description: "Higher quality, slower generation"
|
description: "Legacy DashScope model, higher quality but slower"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notes for DashScope setup:
|
||||||
|
|
||||||
|
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
|
||||||
|
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
||||||
|
- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
||||||
|
|
||||||
### Replicate Model Selection
|
### Replicate Model Selection
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ default_model:
|
|||||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||||
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||||
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
||||||
dashscope: null # e.g., "z-image-turbo"
|
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||||
replicate: null # e.g., "google/nano-banana-pro"
|
replicate: null # e.g., "google/nano-banana-pro"
|
||||||
|
|
||||||
batch:
|
batch:
|
||||||
@@ -88,7 +88,7 @@ default_model:
|
|||||||
google: "gemini-3-pro-image-preview"
|
google: "gemini-3-pro-image-preview"
|
||||||
openai: "gpt-image-1.5"
|
openai: "gpt-image-1.5"
|
||||||
openrouter: "google/gemini-3.1-flash-image-preview"
|
openrouter: "google/gemini-3.1-flash-image-preview"
|
||||||
dashscope: "z-image-turbo"
|
dashscope: "qwen-image-2.0-pro"
|
||||||
replicate: "google/nano-banana-pro"
|
replicate: "google/nano-banana-pro"
|
||||||
batch:
|
batch:
|
||||||
max_workers: 10
|
max_workers: 10
|
||||||
|
|||||||
+14
-10
@@ -2,8 +2,9 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs, ExtendConfig } from "./types.ts";
|
||||||
import {
|
import {
|
||||||
createTaskArgs,
|
createTaskArgs,
|
||||||
detectProvider,
|
detectProvider,
|
||||||
@@ -16,9 +17,9 @@ import {
|
|||||||
normalizeOutputImagePath,
|
normalizeOutputImagePath,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
parseSimpleYaml,
|
parseSimpleYaml,
|
||||||
} from "../../../skills/baoyu-image-gen/scripts/main.ts";
|
} from "./main.ts";
|
||||||
|
|
||||||
function makeArgs(overrides = {}) {
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
return {
|
return {
|
||||||
prompt: null,
|
prompt: null,
|
||||||
promptFiles: [],
|
promptFiles: [],
|
||||||
@@ -39,8 +40,11 @@ function makeArgs(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEnv(t, values) {
|
function useEnv(
|
||||||
const previous = new Map();
|
t: TestContext,
|
||||||
|
values: Record<string, string | null>,
|
||||||
|
): void {
|
||||||
|
const previous = new Map<string, string | undefined>();
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
previous.set(key, process.env[key]);
|
previous.set(key, process.env[key]);
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
@@ -61,7 +65,7 @@ function useEnv(t, values) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeTempDir(prefix) {
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +165,7 @@ test("mergeConfig only fills values missing from CLI args", () => {
|
|||||||
default_quality: "2k",
|
default_quality: "2k",
|
||||||
default_aspect_ratio: "3:2",
|
default_aspect_ratio: "3:2",
|
||||||
default_image_size: "2K",
|
default_image_size: "2K",
|
||||||
},
|
} satisfies Partial<ExtendConfig>,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(merged.provider, "openai");
|
assert.equal(merged.provider, "openai");
|
||||||
@@ -219,7 +223,7 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
|||||||
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||||
});
|
});
|
||||||
|
|
||||||
const extendConfig = {
|
const extendConfig: Partial<ExtendConfig> = {
|
||||||
batch: {
|
batch: {
|
||||||
max_workers: 7,
|
max_workers: 7,
|
||||||
provider_limits: {
|
provider_limits: {
|
||||||
@@ -263,7 +267,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
|||||||
const loaded = await loadBatchTasks(batchFile);
|
const loaded = await loadBatchTasks(batchFile);
|
||||||
assert.equal(loaded.jobs, 2);
|
assert.equal(loaded.jobs, 2);
|
||||||
assert.equal(loaded.batchDir, path.dirname(batchFile));
|
assert.equal(loaded.batchDir, path.dirname(batchFile));
|
||||||
assert.equal(loaded.tasks[0].id, "hero");
|
assert.equal(loaded.tasks[0]?.id, "hero");
|
||||||
|
|
||||||
const taskArgs = createTaskArgs(
|
const taskArgs = createTaskArgs(
|
||||||
makeArgs({
|
makeArgs({
|
||||||
@@ -271,7 +275,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
|||||||
quality: "2k",
|
quality: "2k",
|
||||||
json: true,
|
json: true,
|
||||||
}),
|
}),
|
||||||
loaded.tasks[0],
|
loaded.tasks[0]!,
|
||||||
loaded.batchDir,
|
loaded.batchDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ Environment variables:
|
|||||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||||
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
||||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
|
||||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDefaultModel,
|
||||||
|
getModelFamily,
|
||||||
|
getQwen2SizeFromAspectRatio,
|
||||||
|
getSizeFromAspectRatio,
|
||||||
|
normalizeSize,
|
||||||
|
parseAspectRatio,
|
||||||
|
parseSize,
|
||||||
|
resolveSizeForModel,
|
||||||
|
} from "./dashscope.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
|
||||||
|
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
|
||||||
|
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
|
||||||
|
|
||||||
|
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
|
||||||
|
assert.equal(getDefaultModel(), "qwen-image-max");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||||
|
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
||||||
|
assert.equal(parseAspectRatio("square"), null);
|
||||||
|
assert.equal(parseAspectRatio("-1:2"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
|
||||||
|
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
|
||||||
|
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
|
||||||
|
assert.equal(getModelFamily("z-image-turbo"), "legacy");
|
||||||
|
assert.equal(getModelFamily("wanx-v1"), "legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
|
||||||
|
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||||
|
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||||
|
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
||||||
|
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
|
||||||
|
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
|
||||||
|
const parsed = parseSize(size);
|
||||||
|
assert.ok(parsed);
|
||||||
|
assert.ok(parsed.width * parsed.height >= 512 * 512);
|
||||||
|
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
||||||
|
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||||
|
size: "2048x872",
|
||||||
|
aspectRatio: null,
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
"2048*872",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||||
|
size: "4096x4096",
|
||||||
|
aspectRatio: null,
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
/total pixels between/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSizeForModel("qwen-image-max", {
|
||||||
|
size: null,
|
||||||
|
aspectRatio: "1:1",
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
"1328*1328",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveSizeForModel("qwen-image", {
|
||||||
|
size: "1664x928",
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
quality: "normal",
|
||||||
|
}),
|
||||||
|
"1664*928",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSizeForModel("qwen-image-max", {
|
||||||
|
size: null,
|
||||||
|
aspectRatio: "21:9",
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
/supports only fixed ratios/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSizeForModel("qwen-image-plus", {
|
||||||
|
size: "1024x1024",
|
||||||
|
aspectRatio: null,
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
/support only these sizes/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DashScope size normalization converts WxH into provider format", () => {
|
||||||
|
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
||||||
|
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
||||||
|
});
|
||||||
@@ -1,28 +1,46 @@
|
|||||||
import type { CliArgs } from "../types";
|
import type { CliArgs, Quality } from "../types";
|
||||||
|
|
||||||
export function getDefaultModel(): string {
|
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
|
||||||
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiKey(): string | null {
|
type DashScopeModelSpec = {
|
||||||
return process.env.DASHSCOPE_API_KEY || null;
|
family: DashScopeModelFamily;
|
||||||
}
|
defaultSize: string;
|
||||||
|
};
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
const DEFAULT_MODEL = "qwen-image-2.0-pro";
|
||||||
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;
|
||||||
return base.replace(/\/+$/g, "");
|
const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;
|
||||||
}
|
const SIZE_STEP = 16;
|
||||||
|
const QWEN_NEGATIVE_PROMPT =
|
||||||
|
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";
|
||||||
|
|
||||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
|
||||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
normal: 1024 * 1024,
|
||||||
if (!match) return null;
|
"2k": 1536 * 1536,
|
||||||
const w = parseFloat(match[1]!);
|
};
|
||||||
const h = parseFloat(match[2]!);
|
|
||||||
if (w <= 0 || h <= 0) return null;
|
|
||||||
return { width: w, height: h };
|
|
||||||
}
|
|
||||||
|
|
||||||
const STANDARD_SIZES: [number, number][] = [
|
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
|
||||||
|
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
|
||||||
|
"2:3": { normal: "768*1152", "2k": "1024*1536" },
|
||||||
|
"3:2": { normal: "1152*768", "2k": "1536*1024" },
|
||||||
|
"3:4": { normal: "960*1280", "2k": "1080*1440" },
|
||||||
|
"4:3": { normal: "1280*960", "2k": "1440*1080" },
|
||||||
|
"9:16": { normal: "720*1280", "2k": "1080*1920" },
|
||||||
|
"16:9": { normal: "1280*720", "2k": "1920*1080" },
|
||||||
|
"21:9": { normal: "1344*576", "2k": "2048*872" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {
|
||||||
|
"16:9": "1664*928",
|
||||||
|
"4:3": "1472*1104",
|
||||||
|
"1:1": "1328*1328",
|
||||||
|
"3:4": "1104*1472",
|
||||||
|
"9:16": "928*1664",
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);
|
||||||
|
|
||||||
|
const LEGACY_STANDARD_SIZES: [number, number][] = [
|
||||||
[1024, 1024],
|
[1024, 1024],
|
||||||
[1280, 720],
|
[1280, 720],
|
||||||
[720, 1280],
|
[720, 1280],
|
||||||
@@ -34,7 +52,7 @@ const STANDARD_SIZES: [number, number][] = [
|
|||||||
[864, 1536],
|
[864, 1536],
|
||||||
];
|
];
|
||||||
|
|
||||||
const STANDARD_SIZES_2K: [number, number][] = [
|
const LEGACY_STANDARD_SIZES_2K: [number, number][] = [
|
||||||
[1536, 1536],
|
[1536, 1536],
|
||||||
[2048, 1152],
|
[2048, 1152],
|
||||||
[1152, 2048],
|
[1152, 2048],
|
||||||
@@ -45,9 +63,167 @@ const STANDARD_SIZES_2K: [number, number][] = [
|
|||||||
[2048, 2048],
|
[2048, 2048],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const QWEN_2_SPEC: DashScopeModelSpec = {
|
||||||
|
family: "qwen2",
|
||||||
|
defaultSize: "1024*1024",
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_FIXED_SPEC: DashScopeModelSpec = {
|
||||||
|
family: "qwenFixed",
|
||||||
|
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_SPEC: DashScopeModelSpec = {
|
||||||
|
family: "legacy",
|
||||||
|
defaultSize: "1536*1536",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
|
||||||
|
"qwen-image-2.0-pro": QWEN_2_SPEC,
|
||||||
|
"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,
|
||||||
|
"qwen-image-2.0": QWEN_2_SPEC,
|
||||||
|
"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,
|
||||||
|
"qwen-image-max": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image-plus": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image": QWEN_FIXED_SPEC,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return process.env.DASHSCOPE_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
||||||
|
return base.replace(/\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelSpec(model: string): DashScopeModelSpec {
|
||||||
|
return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelFamily(model: string): DashScopeModelFamily {
|
||||||
|
return getModelSpec(model).family;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuality(quality: CliArgs["quality"]): Quality {
|
||||||
|
return quality === "normal" ? "normal" : "2k";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||||
|
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const w = parseFloat(match[1]!);
|
||||||
|
const h = parseFloat(match[2]!);
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSize(size: string): string {
|
||||||
|
return size.replace("x", "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSize(size: string): { width: number; height: number } | null {
|
||||||
|
const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(width: number, height: number): string {
|
||||||
|
return `${width}*${height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatioValue(ar: string): number | null {
|
||||||
|
const parsed = parseAspectRatio(ar);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return parsed.width / parsed.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {
|
||||||
|
const targetRatio = getRatioValue(ar);
|
||||||
|
if (targetRatio == null) return null;
|
||||||
|
|
||||||
|
let bestKey: string | null = null;
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const candidateRatio = getRatioValue(candidate);
|
||||||
|
if (candidateRatio == null) continue;
|
||||||
|
const diff = Math.abs(candidateRatio - targetRatio);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
bestKey = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestDiff <= tolerance ? bestKey : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToStep(value: number): number {
|
||||||
|
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitToPixelBudget(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
minPixels: number,
|
||||||
|
maxPixels: number,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
let nextWidth = width;
|
||||||
|
let nextHeight = height;
|
||||||
|
let pixels = nextWidth * nextHeight;
|
||||||
|
|
||||||
|
if (pixels > maxPixels) {
|
||||||
|
const scale = Math.sqrt(maxPixels / pixels);
|
||||||
|
nextWidth *= scale;
|
||||||
|
nextHeight *= scale;
|
||||||
|
} else if (pixels < minPixels) {
|
||||||
|
const scale = Math.sqrt(minPixels / pixels);
|
||||||
|
nextWidth *= scale;
|
||||||
|
nextHeight *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roundedWidth = roundToStep(nextWidth);
|
||||||
|
let roundedHeight = roundToStep(nextHeight);
|
||||||
|
pixels = roundedWidth * roundedHeight;
|
||||||
|
|
||||||
|
while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {
|
||||||
|
if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {
|
||||||
|
roundedWidth -= SIZE_STEP;
|
||||||
|
} else if (roundedHeight > SIZE_STEP) {
|
||||||
|
roundedHeight -= SIZE_STEP;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pixels = roundedWidth * roundedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pixels < minPixels) {
|
||||||
|
if (roundedWidth <= roundedHeight) {
|
||||||
|
roundedWidth += SIZE_STEP;
|
||||||
|
} else {
|
||||||
|
roundedHeight += SIZE_STEP;
|
||||||
|
}
|
||||||
|
pixels = roundedWidth * roundedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: roundedWidth, height: roundedHeight };
|
||||||
|
}
|
||||||
|
|
||||||
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||||
const is2k = quality === "2k";
|
const normalizedQuality = normalizeQuality(quality);
|
||||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
|
||||||
|
const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";
|
||||||
|
|
||||||
if (!ar) return defaultSize;
|
if (!ar) return defaultSize;
|
||||||
|
|
||||||
@@ -55,86 +231,157 @@ export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["qual
|
|||||||
if (!parsed) return defaultSize;
|
if (!parsed) return defaultSize;
|
||||||
|
|
||||||
const targetRatio = parsed.width / parsed.height;
|
const targetRatio = parsed.width / parsed.height;
|
||||||
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
|
|
||||||
|
|
||||||
let best = defaultSize;
|
let best = defaultSize;
|
||||||
let bestDiff = Infinity;
|
let bestDiff = Infinity;
|
||||||
|
|
||||||
for (const [w, h] of sizes) {
|
for (const [width, height] of sizes) {
|
||||||
const diff = Math.abs(w / h - targetRatio);
|
const diff = Math.abs(width / height - targetRatio);
|
||||||
if (diff < bestDiff) {
|
if (diff < bestDiff) {
|
||||||
bestDiff = diff;
|
bestDiff = diff;
|
||||||
best = `${w}*${h}`;
|
best = formatSize(width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSize(size: string): string {
|
export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||||
return size.replace("x", "*");
|
const normalizedQuality = normalizeQuality(quality);
|
||||||
|
|
||||||
|
if (!ar) {
|
||||||
|
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));
|
||||||
|
if (recommendedRatio) {
|
||||||
|
return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAspectRatio(ar);
|
||||||
|
if (!parsed) {
|
||||||
|
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRatio = parsed.width / parsed.height;
|
||||||
|
const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];
|
||||||
|
const rawWidth = Math.sqrt(targetPixels * targetRatio);
|
||||||
|
const rawHeight = Math.sqrt(targetPixels / targetRatio);
|
||||||
|
const fitted = fitToPixelBudget(
|
||||||
|
rawWidth,
|
||||||
|
rawHeight,
|
||||||
|
MIN_QWEN_2_TOTAL_PIXELS,
|
||||||
|
MAX_QWEN_2_TOTAL_PIXELS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return formatSize(fitted.width, fitted.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateImage(
|
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||||
prompt: string,
|
if (quality === "normal") {
|
||||||
model: string,
|
console.warn(
|
||||||
args: CliArgs
|
"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."
|
||||||
): Promise<Uint8Array> {
|
|
||||||
const apiKey = getApiKey();
|
|
||||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
|
||||||
|
|
||||||
if (args.referenceImages.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
if (!ar) return QWEN_FIXED_SPEC.defaultSize;
|
||||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
|
||||||
|
|
||||||
const body = {
|
const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));
|
||||||
model,
|
if (!ratioKey) {
|
||||||
input: {
|
throw new Error(
|
||||||
messages: [
|
`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +
|
||||||
{
|
`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`
|
||||||
role: "user",
|
);
|
||||||
content: [{ text: prompt }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
prompt_extend: false,
|
|
||||||
size,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`Generating image with DashScope (${model})...`, { size });
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await res.json() as {
|
return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;
|
||||||
output?: {
|
}
|
||||||
result_image?: string;
|
|
||||||
choices?: Array<{
|
function validateSizeFormat(size: string): { width: number; height: number } {
|
||||||
message?: {
|
const parsed = parseSize(size);
|
||||||
content?: Array<{ image?: string }>;
|
if (!parsed) {
|
||||||
};
|
throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);
|
||||||
}>;
|
}
|
||||||
};
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQwen2Size(size: string): string {
|
||||||
|
const normalized = normalizeSize(size);
|
||||||
|
const parsed = validateSizeFormat(normalized);
|
||||||
|
const totalPixels = parsed.width * parsed.height;
|
||||||
|
if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {
|
||||||
|
throw new Error(
|
||||||
|
`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +
|
||||||
|
`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQwenFixedSize(size: string): string {
|
||||||
|
const normalized = normalizeSize(size);
|
||||||
|
validateSizeFormat(normalized);
|
||||||
|
if (!QWEN_FIXED_SIZES.includes(normalized)) {
|
||||||
|
throw new Error(
|
||||||
|
`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +
|
||||||
|
`Received ${normalized}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSizeForModel(
|
||||||
|
model: string,
|
||||||
|
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
|
||||||
|
): string {
|
||||||
|
const spec = getModelSpec(model);
|
||||||
|
|
||||||
|
if (args.size) {
|
||||||
|
if (spec.family === "qwen2") return validateQwen2Size(args.size);
|
||||||
|
if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);
|
||||||
|
validateSizeFormat(args.size);
|
||||||
|
return normalizeSize(args.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.family === "qwen2") {
|
||||||
|
return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.family === "qwenFixed") {
|
||||||
|
return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParameters(
|
||||||
|
family: DashScopeModelFamily,
|
||||||
|
size: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const parameters: Record<string, unknown> = {
|
||||||
|
prompt_extend: false,
|
||||||
|
size,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (family === "qwen2" || family === "qwenFixed") {
|
||||||
|
parameters.watermark = false;
|
||||||
|
parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashScopeResponse = {
|
||||||
|
output?: {
|
||||||
|
result_image?: string;
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: Array<{ image?: string }>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {
|
||||||
let imageData: string | null = null;
|
let imageData: string | null = null;
|
||||||
|
|
||||||
if (result.output?.result_image) {
|
if (result.output?.result_image) {
|
||||||
@@ -163,3 +410,54 @@ export async function generateImage(
|
|||||||
|
|
||||||
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||||
|
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = getModelSpec(model);
|
||||||
|
const size = resolveSizeForModel(model, args);
|
||||||
|
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
input: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ text: prompt }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
parameters: buildParameters(spec.family, size),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size });
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json() as DashScopeResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
|
|||||||
+31
-12
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
addAspectRatioToPrompt,
|
addAspectRatioToPrompt,
|
||||||
buildGoogleUrl,
|
buildGoogleUrl,
|
||||||
@@ -11,10 +12,13 @@ import {
|
|||||||
isGoogleImagen,
|
isGoogleImagen,
|
||||||
isGoogleMultimodal,
|
isGoogleMultimodal,
|
||||||
normalizeGoogleModelId,
|
normalizeGoogleModelId,
|
||||||
} from "../../../skills/baoyu-image-gen/scripts/providers/google.ts";
|
} from "./google.ts";
|
||||||
|
|
||||||
function useEnv(t, values) {
|
function useEnv(
|
||||||
const previous = new Map();
|
t: TestContext,
|
||||||
|
values: Record<string, string | null>,
|
||||||
|
): void {
|
||||||
|
const previous = new Map<string, string | undefined>();
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
previous.set(key, process.env[key]);
|
previous.set(key, process.env[key]);
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
@@ -35,6 +39,27 @@ function useEnv(t, values) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test("Google provider helpers normalize model IDs and select image size defaults", () => {
|
test("Google provider helpers normalize model IDs and select image size defaults", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
|
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
|
||||||
@@ -42,14 +67,8 @@ test("Google provider helpers normalize model IDs and select image size defaults
|
|||||||
);
|
);
|
||||||
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
|
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
|
||||||
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
|
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
|
||||||
assert.equal(
|
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
|
||||||
getGoogleImageSize({ imageSize: null, quality: "2k" }),
|
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
|
||||||
"2K",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
getGoogleImageSize({ imageSize: "4K", quality: "normal" }),
|
|
||||||
"4K",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
|
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
getMimeType,
|
getMimeType,
|
||||||
getOpenAISize,
|
getOpenAISize,
|
||||||
parseAspectRatio,
|
parseAspectRatio,
|
||||||
} from "../../../skills/baoyu-image-gen/scripts/providers/openai.ts";
|
} from "./openai.ts";
|
||||||
|
|
||||||
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
||||||
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||||
+19
-6
@@ -1,17 +1,30 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
buildInput,
|
buildInput,
|
||||||
extractOutputUrl,
|
extractOutputUrl,
|
||||||
parseModelId,
|
parseModelId,
|
||||||
} from "../../../skills/baoyu-image-gen/scripts/providers/replicate.ts";
|
} from "./replicate.ts";
|
||||||
|
|
||||||
function makeArgs(overrides = {}) {
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
return {
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
aspectRatio: null,
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
quality: null,
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
n: 1,
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,20 +82,20 @@ test("Replicate input builder maps aspect ratio, image count, quality, and refs"
|
|||||||
|
|
||||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
extractOutputUrl({ output: "https://example.com/a.png" }),
|
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||||
"https://example.com/a.png",
|
"https://example.com/a.png",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
extractOutputUrl({ output: ["https://example.com/b.png"] }),
|
extractOutputUrl({ output: ["https://example.com/b.png"] } as never),
|
||||||
"https://example.com/b.png",
|
"https://example.com/b.png",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
extractOutputUrl({ output: { url: "https://example.com/c.png" } }),
|
extractOutputUrl({ output: { url: "https://example.com/c.png" } } as never),
|
||||||
"https://example.com/c.png",
|
"https://example.com/c.png",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => extractOutputUrl({ output: { invalid: true } }),
|
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||||
/Unexpected Replicate output format/,
|
/Unexpected Replicate output format/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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...",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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,
|
||||||
|
resolveColorToken,
|
||||||
|
resolveFontFamilyToken,
|
||||||
|
resolveMarkdownStyle,
|
||||||
|
resolveRenderOptions,
|
||||||
|
} from "./document.ts";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
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("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>`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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...",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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,
|
||||||
|
resolveColorToken,
|
||||||
|
resolveFontFamilyToken,
|
||||||
|
resolveMarkdownStyle,
|
||||||
|
resolveRenderOptions,
|
||||||
|
} from "./document.ts";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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("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>`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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...",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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,
|
||||||
|
resolveColorToken,
|
||||||
|
resolveFontFamilyToken,
|
||||||
|
resolveMarkdownStyle,
|
||||||
|
resolveRenderOptions,
|
||||||
|
} from "./document.ts";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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("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>`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
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 {
|
||||||
|
findChromeExecutable,
|
||||||
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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`);
|
||||||
|
});
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getSizeFromAspectRatio,
|
|
||||||
normalizeSize,
|
|
||||||
parseAspectRatio,
|
|
||||||
} from "../../../skills/baoyu-image-gen/scripts/providers/dashscope.ts";
|
|
||||||
|
|
||||||
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
|
||||||
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
|
||||||
assert.equal(parseAspectRatio("square"), null);
|
|
||||||
assert.equal(parseAspectRatio("-1:2"), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("DashScope size selection picks the closest supported size per quality preset", () => {
|
|
||||||
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
|
||||||
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
|
||||||
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
|
||||||
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("DashScope size normalization converts WxH into provider format", () => {
|
|
||||||
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
|
||||||
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user