271 lines
9.0 KiB
TypeScript
271 lines
9.0 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { DocPage } from "../src/lib/docs";
|
|
import { parseFrontmatter } from "../src/lib/frontmatter";
|
|
import { DOC_SLUGS } from "../src/lib/nav";
|
|
import {
|
|
buildSearchRecords,
|
|
createSearchSnippet,
|
|
findSearchMatchRanges,
|
|
markdownToSearchText,
|
|
normalizeSearchText,
|
|
searchRecords,
|
|
} from "../src/lib/search";
|
|
import { getSearchShortcutLabel } from "../src/lib/shortcut";
|
|
|
|
const docs: DocPage[] = [
|
|
{
|
|
slug: "introduction",
|
|
lang: "zh",
|
|
title: "产品介绍",
|
|
description: "",
|
|
body: [
|
|
"PenguinHarness 是一个开源的 AI Agent Harness。",
|
|
"",
|
|
"## 产品组成",
|
|
"",
|
|
"Web App 提供多 Session 对话和 Trace 观测。",
|
|
"",
|
|
"### 模型网关",
|
|
"",
|
|
"统一接入在线与本地模型。",
|
|
].join("\n"),
|
|
},
|
|
{
|
|
slug: "server-api",
|
|
lang: "en",
|
|
title: "Server API",
|
|
description: "",
|
|
body: ["## Predict", "", "Call `client.predict` to stream an agent response."].join("\n"),
|
|
},
|
|
{
|
|
slug: "configuration",
|
|
lang: "en",
|
|
title: "Configuration",
|
|
description: "Environment variables and configuration files.",
|
|
body: ["## Vault", "", "Store secrets outside the project."].join("\n"),
|
|
},
|
|
];
|
|
const contentDir = join(__dirname, "..", "content");
|
|
const contentFiles = ["zh", "en"].flatMap((lang) => DOC_SLUGS.map((slug) => `${slug}.${lang}.md`));
|
|
|
|
describe("docs search", () => {
|
|
it("normalizes width, case, and whitespace", () => {
|
|
expect(normalizeSearchText(" PENGUIN Harness ")).toBe("penguin harness");
|
|
});
|
|
|
|
it("maps normalized matches back to their original display text", () => {
|
|
const text = "使用 Client.Predict 调用本地模型";
|
|
const ranges = findSearchMatchRanges(text, "client.predict 模型");
|
|
expect(ranges.map(({ start, end }) => text.slice(start, end))).toEqual([
|
|
"Client.Predict",
|
|
"模型",
|
|
]);
|
|
});
|
|
|
|
it("uses locale-independent case folding for English documentation", () => {
|
|
expect(normalizeSearchText("INTERFACES INPUT")).toBe("interfaces input");
|
|
});
|
|
|
|
it("maps composed queries to decomposed source graphemes", () => {
|
|
const text = "Cafe\u0301 configuration";
|
|
const ranges = findSearchMatchRanges(text, "café");
|
|
expect(ranges.map(({ start, end }) => text.slice(start, end))).toEqual(["Cafe\u0301"]);
|
|
});
|
|
|
|
it("merges overlapping highlight terms", () => {
|
|
expect(findSearchMatchRanges("PenguinHarness", "penguin penguinharness")).toEqual([
|
|
{ start: 0, end: 14 },
|
|
]);
|
|
});
|
|
|
|
it("keeps link labels and inline code while removing Markdown syntax", () => {
|
|
expect(markdownToSearchText("Use [`client.predict`](/api) with **streaming**.")).toBe(
|
|
"Use client.predict with streaming.",
|
|
);
|
|
});
|
|
|
|
it("preserves identifiers and angle-bracket placeholders inside code", () => {
|
|
const markdown = [
|
|
"Use `event_msg`, `PENGUIN_HOME`, and `agent_state/skills/<name>/`.",
|
|
"",
|
|
"```ts",
|
|
"listTools(): Promise<ToolDefinition[]>;",
|
|
'const type = "partial_tool_call_output";',
|
|
"```",
|
|
].join("\n");
|
|
|
|
expect(markdownToSearchText(markdown)).toBe(
|
|
'Use event_msg, PENGUIN_HOME, and agent_state/skills/<name>/. listTools(): Promise<ToolDefinition[]>; const type = "partial_tool_call_output";',
|
|
);
|
|
});
|
|
|
|
it("removes paired formatting without changing literal identifier punctuation", () => {
|
|
expect(
|
|
markdownToSearchText(
|
|
"**Bold** __strong__ _italic_ ~~old~~ context_engine foo__bar__baz partial_* model.* ~/.penguin <name> <https://example.com>",
|
|
),
|
|
).toBe(
|
|
"Bold strong italic old context_engine foo__bar__baz partial_* model.* ~/.penguin <name> https://example.com",
|
|
);
|
|
});
|
|
|
|
it("flattens Markdown tables without changing literal pipes in prose", () => {
|
|
expect(
|
|
markdownToSearchText(
|
|
["Keep A|B searchable.", "", "| Name | Value |", "| --- | --- |", "| alpha | beta |"].join(
|
|
"\n",
|
|
),
|
|
),
|
|
).toBe("Keep A|B searchable. Name Value alpha beta");
|
|
});
|
|
|
|
it("finds documented identifiers by their exact spelling", () => {
|
|
const codeDocs: DocPage[] = [
|
|
{
|
|
slug: "interfaces",
|
|
lang: "en",
|
|
title: "Interfaces",
|
|
description: "",
|
|
body: [
|
|
"## Environment",
|
|
"",
|
|
"Returns `Promise<ToolDefinition[]>` and emits `partial_tool_call_output`.",
|
|
].join("\n"),
|
|
},
|
|
];
|
|
const records = buildSearchRecords(codeDocs);
|
|
|
|
expect(searchRecords(records, "Promise<ToolDefinition[]>")).toHaveLength(1);
|
|
expect(searchRecords(records, "partial_tool_call_output")).toHaveLength(1);
|
|
});
|
|
|
|
it("finds identifier headings from the real OmniMessage documentation", () => {
|
|
for (const lang of ["en", "zh"] as const) {
|
|
const { meta, body } = parseFrontmatter(
|
|
readFileSync(join(contentDir, `omni-message.${lang}.md`), "utf8"),
|
|
);
|
|
const records = buildSearchRecords([
|
|
{
|
|
slug: "omni-message",
|
|
lang,
|
|
title: meta.title ?? "OmniMessage",
|
|
description: meta.description ?? "",
|
|
body,
|
|
},
|
|
]);
|
|
|
|
for (const heading of ["session_meta", "model_msg", "event_msg", "stop_reason"]) {
|
|
expect(
|
|
searchRecords(records, heading, 50).some((result) =>
|
|
result.record.heading.startsWith(heading),
|
|
),
|
|
`${heading} should resolve to its real ${lang} heading`,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("preserves visible underscore identifiers across every documentation file", () => {
|
|
for (const file of contentFiles) {
|
|
const { body } = parseFrontmatter(readFileSync(join(contentDir, file), "utf8"));
|
|
const visibleMarkdown = body
|
|
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
const identifiers = new Set(
|
|
visibleMarkdown.match(/[\p{L}\p{N}]+(?:_[\p{L}\p{N}*]+)+/gu) ?? [],
|
|
);
|
|
const searchText = markdownToSearchText(body);
|
|
|
|
for (const identifier of identifiers) {
|
|
expect(searchText, `${file} should preserve ${identifier}`).toContain(identifier);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("maps normalized snippet offsets back to grapheme-safe source boundaries", () => {
|
|
const text = `${"e\u0301".repeat(100)} TARGET value`;
|
|
const snippet = createSearchSnippet(text, "target");
|
|
const visibleSnippet = snippet.replace(/^…/, "");
|
|
|
|
expect(snippet).toContain("TARGET");
|
|
expect(visibleSnippet).not.toMatch(/^\u0301/u);
|
|
expect(findSearchMatchRanges(snippet, "target")).not.toHaveLength(0);
|
|
});
|
|
|
|
it("builds page and heading records with deep-link anchors", () => {
|
|
const records = buildSearchRecords(docs);
|
|
expect(records).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
slug: "introduction",
|
|
anchor: "",
|
|
pageTitle: "产品介绍",
|
|
}),
|
|
expect.objectContaining({
|
|
slug: "introduction",
|
|
anchor: "产品组成",
|
|
heading: "产品组成",
|
|
}),
|
|
expect.objectContaining({
|
|
slug: "introduction",
|
|
anchor: "模型网关",
|
|
heading: "模型网关",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("finds Chinese section content and ranks the matching section first", () => {
|
|
const results = searchRecords(buildSearchRecords(docs), "本地模型");
|
|
expect(results[0]?.record).toMatchObject({
|
|
slug: "introduction",
|
|
anchor: "模型网关",
|
|
});
|
|
});
|
|
|
|
it("finds punctuation-bearing API names inside inline code", () => {
|
|
const results = searchRecords(buildSearchRecords(docs), "client.predict");
|
|
expect(results[0]?.record).toMatchObject({
|
|
slug: "server-api",
|
|
anchor: "predict",
|
|
});
|
|
});
|
|
|
|
it("returns only the page record for a page-title-only query", () => {
|
|
const results = searchRecords(buildSearchRecords(docs), "产品介绍");
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]?.record.anchor).toBe("");
|
|
});
|
|
|
|
it("keeps a searchable page result when a document starts with a heading", () => {
|
|
const results = searchRecords(buildSearchRecords(docs), "Configuration");
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]).toMatchObject({
|
|
record: {
|
|
slug: "configuration",
|
|
anchor: "",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("returns no results for a blank or unmatched query", () => {
|
|
const records = buildSearchRecords(docs);
|
|
expect(searchRecords(records, " ")).toEqual([]);
|
|
expect(searchRecords(records, "does-not-exist")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("docs search shortcut", () => {
|
|
it("uses the Command key label on Apple platforms", () => {
|
|
expect(getSearchShortcutLabel("MacIntel")).toBe("⌘ K");
|
|
expect(getSearchShortcutLabel("iPad")).toBe("⌘ K");
|
|
});
|
|
|
|
it("uses the Control key label on non-Apple platforms", () => {
|
|
expect(getSearchShortcutLabel("Win32")).toBe("Ctrl K");
|
|
expect(getSearchShortcutLabel("Linux x86_64")).toBe("Ctrl K");
|
|
});
|
|
});
|