test: migrate tests from centralized mjs to colocated TypeScript
Move test files from tests/ directory to colocate with source code, convert from .mjs to .ts using tsx runner, add workspaces and npm cache to CI workflow.
This commit is contained in:
parent
484b00109f
commit
774ad784d8
|
|
@ -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
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -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(
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
getSizeFromAspectRatio,
|
getSizeFromAspectRatio,
|
||||||
normalizeSize,
|
normalizeSize,
|
||||||
parseAspectRatio,
|
parseAspectRatio,
|
||||||
} from "../../../skills/baoyu-image-gen/scripts/providers/dashscope.ts";
|
} from "./dashscope.ts";
|
||||||
|
|
||||||
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||||
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
||||||
|
|
@ -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) => {
|
||||||
|
|
@ -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 });
|
||||||
|
|
@ -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/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
Loading…
Reference in New Issue