mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:31:33 +00:00
docs: generate docs map at publish time (#117398)
* docs: generate docs map at publish time * fix: canonicalize Docker pack entry path * fix: preserve package lifecycle ownership
This commit is contained in:
committed by
GitHub
parent
e4d1b7e0d3
commit
385f1dee16
@@ -1,8 +1,9 @@
|
||||
// docs-list tests cover source docs metadata discovery for docs-aware tooling.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
|
||||
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -13,8 +14,8 @@ function makeTempRepoRoot(prefix: string): string {
|
||||
return makeTempDir(tempDirs, prefix);
|
||||
}
|
||||
|
||||
function runDocsList(cwd: string): string {
|
||||
return execFileSync(process.execPath, [docsListScriptPath], {
|
||||
function runDocsList(cwd: string, args: string[] = []): string {
|
||||
return execFileSync(process.execPath, [docsListScriptPath, ...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -25,6 +26,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("docs-list", () => {
|
||||
it("reports a concise error outside a source checkout", () => {
|
||||
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-list-missing-");
|
||||
const result = spawnSync(process.execPath, [docsListScriptPath], {
|
||||
cwd: tempRepoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toBe("docs:list: missing docs directory. Run from repo root.\n");
|
||||
});
|
||||
|
||||
it("prints single-line read_when strings as read hints", () => {
|
||||
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-list-");
|
||||
mkdirSync(path.join(tempRepoRoot, "docs"), { recursive: true });
|
||||
@@ -43,4 +55,58 @@ read_when: "Read this page when the hint is inline."
|
||||
expect(output).toContain("page.md - Single-line read_when page");
|
||||
expect(output).toContain("Read when: Read this page when the hint is inline.");
|
||||
});
|
||||
|
||||
it("renders the publish docs map on demand without creating a mirror", () => {
|
||||
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-headings-");
|
||||
mkdirSync(path.join(tempRepoRoot, "docs", "nested"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tempRepoRoot, "docs", "page.md"),
|
||||
`---
|
||||
summary: "Page"
|
||||
---
|
||||
# Visible title
|
||||
|
||||
## \`API[*]\` <script>alert(1)</script>
|
||||
|
||||
### <scr<script>ipt>alert(1)</script>
|
||||
|
||||
#### \`\`
|
||||
|
||||
\`\`\`md
|
||||
### Hidden fenced heading
|
||||
\`\`\`
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(path.join(tempRepoRoot, "docs", "nested", "index.mdx"), "# Nested\n");
|
||||
writeFileSync(path.join(tempRepoRoot, "docs", "AGENTS.md"), "# Instructions\n");
|
||||
|
||||
const output = runDocsList(tempRepoRoot, ["--headings"]);
|
||||
|
||||
expect(output).toContain("## page.md\n\n- Route: /page");
|
||||
expect(output).toContain(" - H1: Visible title");
|
||||
expect(output).toContain(" - H2: `API[*]` <script>alert(1)</script>");
|
||||
expect(output).toContain(" - H3: <scr<script>ipt>alert(1)</script>");
|
||||
expect(output).toContain(" - H4: ``");
|
||||
expect(output).toContain("## nested/index.mdx\n\n- Route: /nested");
|
||||
expect(output).not.toContain("Hidden fenced heading");
|
||||
expect(output).not.toContain("AGENTS.md");
|
||||
expect(existsSync(path.join(tempRepoRoot, "docs", "docs_map.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes injected Windows paths for nested page routes", () => {
|
||||
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-headings-windows-");
|
||||
const docsDir = path.join(tempRepoRoot, "docs");
|
||||
mkdirSync(path.join(docsDir, "nested"), { recursive: true });
|
||||
writeFileSync(path.join(docsDir, "nested", "index.mdx"), "# Nested index\n");
|
||||
writeFileSync(path.join(docsDir, "nested", "page.md"), "# Nested page\n");
|
||||
|
||||
const output = renderDocsHeadingMap(docsDir, {
|
||||
relativePath: (base, fullPath) => path.relative(base, fullPath).replace(/[\\/]+/gu, "\\"),
|
||||
});
|
||||
|
||||
expect(output).toContain("## nested/index.mdx\n\n- Route: /nested");
|
||||
expect(output).toContain("## nested/page.md\n\n- Route: /nested/page");
|
||||
expect(output).not.toContain("\\");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
|
||||
import {
|
||||
composeDocsConfig,
|
||||
parseArgs,
|
||||
reportOrphanLocaleDocs,
|
||||
writePublishedDocsMap,
|
||||
} from "../../scripts/docs-sync-publish.mjs";
|
||||
|
||||
function collectPages(entry: unknown, pages: string[] = []): string[] {
|
||||
@@ -33,6 +35,18 @@ function collectPages(entry: unknown, pages: string[] = []): string[] {
|
||||
}
|
||||
|
||||
describe("docs-sync-publish", () => {
|
||||
it("materializes the public docs map only in the publish tree", () => {
|
||||
const targetDocsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-map-publish-"));
|
||||
try {
|
||||
const outputPath = writePublishedDocsMap(targetDocsDir);
|
||||
expect(fs.readFileSync(outputPath, "utf8")).toBe(
|
||||
renderDocsHeadingMap(path.resolve(import.meta.dirname, "../../docs")),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(targetDocsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("parses docs sync provenance args", () => {
|
||||
expect(
|
||||
parseArgs([
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { testing } from "../../scripts/generate-docs-map.mjs";
|
||||
|
||||
describe("generate docs map", () => {
|
||||
it("renders heading HTML as text", () => {
|
||||
expect(testing.cleanHeadingText("`API` <script>alert(1)</script>")).toBe(
|
||||
"API <script>alert(1)</script>",
|
||||
);
|
||||
expect(testing.cleanHeadingText("<scr<script>ipt>alert(1)</script>")).toBe(
|
||||
"<scr<script>ipt>alert(1)</script>",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves Markdown syntax inside inline code", () => {
|
||||
expect(testing.cleanHeadingText("`agents.entries.*.contextLimits` and *defaults*")).toBe(
|
||||
"`agents.entries.*.contextLimits` and defaults",
|
||||
);
|
||||
expect(testing.cleanHeadingText("``")).toBe(
|
||||
"``",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -11,12 +11,24 @@ import {
|
||||
resolveRuntimePackEnvironment,
|
||||
resolveRuntimePackPlan,
|
||||
resolveWorkspaceInstallPlan,
|
||||
restoreRuntimePack,
|
||||
rewriteWorkspaceDependencyVersions,
|
||||
runPreparedRuntimePack,
|
||||
} from "../../scripts/ocm-npm-workspace-deps.mjs";
|
||||
import { restorePrepackArtifacts } from "../../scripts/openclaw-postpack.mjs";
|
||||
import { preparePackageChangelog } from "../../scripts/package-changelog.mjs";
|
||||
import { preparePackageDocsMap } from "../../scripts/package-docs-map.mjs";
|
||||
|
||||
const adapterPath = fileURLToPath(
|
||||
new URL("../../scripts/ocm-npm-workspace-deps.mjs", import.meta.url),
|
||||
);
|
||||
const packageDocsMapPath = fileURLToPath(
|
||||
new URL("../../scripts/package-docs-map.mjs", import.meta.url),
|
||||
);
|
||||
const packageChangelogPath = fileURLToPath(
|
||||
new URL("../../scripts/package-changelog.mjs", import.meta.url),
|
||||
);
|
||||
const postpackPath = fileURLToPath(new URL("../../scripts/openclaw-postpack.mjs", import.meta.url));
|
||||
|
||||
describe("OCM npm workspace dependency adapter", () => {
|
||||
it("allows Unreleased notes only for non-publishing pack commands", () => {
|
||||
@@ -81,6 +93,106 @@ describe("OCM npm workspace dependency adapter", () => {
|
||||
).toThrow("runtime pack commit must be a full 40-character hexadecimal SHA");
|
||||
});
|
||||
|
||||
it("does not let a failed concurrent owner restore the active pack lifecycle", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-pack-owner-"));
|
||||
const docsDir = join(root, "docs");
|
||||
const mapPath = join(docsDir, "docs_map.md");
|
||||
const receiptPath = join(root, ".artifacts", "package-docs-map", "receipt.json");
|
||||
const changelogBackupPath = join(
|
||||
root,
|
||||
".artifacts",
|
||||
"package-changelog",
|
||||
"CHANGELOG.md.prepack-backup",
|
||||
);
|
||||
const sourceMap = "# Docs map source\n";
|
||||
const sourceChangelog = `# Changelog
|
||||
|
||||
## 2026.8.1
|
||||
- Current release notes with enough detail for package validation.
|
||||
|
||||
## 2026.7.1
|
||||
- Previous release notes with enough detail for package validation.
|
||||
`;
|
||||
mkdirSync(docsDir, { recursive: true });
|
||||
writeFileSync(join(docsDir, "page.md"), "# Package docs\n");
|
||||
writeFileSync(mapPath, sourceMap);
|
||||
writeFileSync(join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
|
||||
writeFileSync(join(root, "CHANGELOG.md"), sourceChangelog);
|
||||
|
||||
try {
|
||||
await preparePackageDocsMap(root);
|
||||
await preparePackageChangelog(root);
|
||||
const activeMap = readFileSync(mapPath, "utf8");
|
||||
const activeChangelog = readFileSync(join(root, "CHANGELOG.md"), "utf8");
|
||||
let packCalled = false;
|
||||
|
||||
expect(() =>
|
||||
runPreparedRuntimePack(
|
||||
() =>
|
||||
execFileSync(process.execPath, [packageDocsMapPath, "prepare"], {
|
||||
cwd: root,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
() => {
|
||||
packCalled = true;
|
||||
return 0;
|
||||
},
|
||||
() => execFileSync(process.execPath, [postpackPath], { cwd: root, stdio: "pipe" }),
|
||||
),
|
||||
).toThrow();
|
||||
|
||||
expect(packCalled).toBe(false);
|
||||
expect(existsSync(receiptPath)).toBe(true);
|
||||
expect(existsSync(changelogBackupPath)).toBe(true);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(activeMap);
|
||||
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(activeChangelog);
|
||||
|
||||
await restorePrepackArtifacts(root);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(sourceMap);
|
||||
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores a prepared legacy source fixture through its changelog owner", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-historical-pack-"));
|
||||
const scriptsDir = join(root, "scripts");
|
||||
const sourceChangelog = `# Changelog
|
||||
|
||||
## 2026.8.1
|
||||
- Current release notes with enough detail for package validation.
|
||||
|
||||
## 2026.7.1
|
||||
- Previous release notes with enough detail for package validation.
|
||||
`;
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
writeFileSync(join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
|
||||
writeFileSync(join(root, "CHANGELOG.md"), sourceChangelog);
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
join(scriptsDir, "package-changelog.mjs"),
|
||||
readFileSync(packageChangelogPath, "utf8"),
|
||||
);
|
||||
execFileSync(process.execPath, ["scripts/package-changelog.mjs", "prepare"], {
|
||||
cwd: root,
|
||||
stdio: "pipe",
|
||||
});
|
||||
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).not.toBe(sourceChangelog);
|
||||
expect(existsSync(join(root, "scripts", "openclaw-postpack.mjs"))).toBe(false);
|
||||
|
||||
restoreRuntimePack(process.env, root);
|
||||
|
||||
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
|
||||
expect(
|
||||
existsSync(join(root, ".artifacts", "package-changelog", "CHANGELOG.md.prepack-backup")),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves workspace package directories", () => {
|
||||
expect(
|
||||
parseWorkspaceDependencyDirs(["packages/ai", "extensions/example"].join(delimiter), "/repo"),
|
||||
|
||||
131
test/scripts/package-docs-map.test.ts
Normal file
131
test/scripts/package-docs-map.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
|
||||
import { restorePrepackArtifacts } from "../../scripts/openclaw-postpack.mjs";
|
||||
import { preparePackageChangelog } from "../../scripts/package-changelog.mjs";
|
||||
import { preparePackageDocsMap, restorePackageDocsMap } from "../../scripts/package-docs-map.mjs";
|
||||
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const sourceChangelog = `# Changelog
|
||||
|
||||
## 2026.8.1
|
||||
- Current release notes with enough detail for package validation.
|
||||
|
||||
## 2026.7.1
|
||||
- Previous release notes with enough detail for package validation.
|
||||
`;
|
||||
|
||||
function makePackageRoot(): string {
|
||||
const root = makeTempDir(tempDirs, "openclaw-package-docs-map-");
|
||||
mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||
writeFileSync(path.join(root, "docs", "page.md"), "# Package docs\n", "utf8");
|
||||
writeFileSync(path.join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
|
||||
writeFileSync(path.join(root, "CHANGELOG.md"), sourceChangelog);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
describe("package docs map", () => {
|
||||
it("materializes exact generated bytes and removes only its transient copy", async () => {
|
||||
const root = makePackageRoot();
|
||||
const mapPath = path.join(root, "docs", "docs_map.md");
|
||||
|
||||
await expect(preparePackageDocsMap(root)).resolves.toBe(true);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
|
||||
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
|
||||
expect(existsSync(mapPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an identical map that existed before packaging", async () => {
|
||||
const root = makePackageRoot();
|
||||
const mapPath = path.join(root, "docs", "docs_map.md");
|
||||
const content = renderDocsHeadingMap(path.join(root, "docs"));
|
||||
writeFileSync(mapPath, content, "utf8");
|
||||
|
||||
await expect(preparePackageDocsMap(root)).resolves.toBe(false);
|
||||
await expect(restorePackageDocsMap(root)).resolves.toBe(false);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(content);
|
||||
});
|
||||
|
||||
it("restores a tracked source stub after packaging", async () => {
|
||||
const root = makePackageRoot();
|
||||
const mapPath = path.join(root, "docs", "docs_map.md");
|
||||
const stub = "# Docs map source\n";
|
||||
writeFileSync(mapPath, stub, "utf8");
|
||||
|
||||
await expect(preparePackageDocsMap(root)).resolves.toBe(true);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
|
||||
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(stub);
|
||||
});
|
||||
|
||||
it("serializes concurrent package preparations with the receipt", async () => {
|
||||
const root = makePackageRoot();
|
||||
const mapPath = path.join(root, "docs", "docs_map.md");
|
||||
const stub = "# Docs map source\n";
|
||||
writeFileSync(mapPath, stub, "utf8");
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
preparePackageDocsMap(root),
|
||||
preparePackageDocsMap(root),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
expect(rejected).toMatchObject({
|
||||
reason: expect.objectContaining({
|
||||
code: "PACKAGE_DOCS_MAP_ACTIVE",
|
||||
message: expect.stringContaining("node scripts/openclaw-postpack.mjs"),
|
||||
}),
|
||||
});
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
|
||||
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(stub);
|
||||
});
|
||||
|
||||
it("refuses to remove a transient map changed after prepack", async () => {
|
||||
const root = makePackageRoot();
|
||||
const mapPath = path.join(root, "docs", "docs_map.md");
|
||||
await preparePackageDocsMap(root);
|
||||
writeFileSync(mapPath, "operator change\n", "utf8");
|
||||
|
||||
await expect(restorePackageDocsMap(root)).rejects.toThrow("changed after prepack");
|
||||
expect(readFileSync(mapPath, "utf8")).toBe("operator change\n");
|
||||
});
|
||||
|
||||
it("keeps the lifecycle lock until interrupted changelog state is restored", async () => {
|
||||
const root = makePackageRoot();
|
||||
const mapPath = path.join(root, "docs", "docs_map.md");
|
||||
const receiptPath = path.join(root, ".artifacts", "package-docs-map", "receipt.json");
|
||||
const changelogBackupPath = path.join(
|
||||
root,
|
||||
".artifacts",
|
||||
"package-changelog",
|
||||
"CHANGELOG.md.prepack-backup",
|
||||
);
|
||||
const stub = "# Docs map source\n";
|
||||
writeFileSync(mapPath, stub);
|
||||
|
||||
await preparePackageDocsMap(root);
|
||||
await preparePackageChangelog(root);
|
||||
const packagedChangelog = readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
|
||||
writeFileSync(path.join(root, "CHANGELOG.md"), "operator change\n");
|
||||
|
||||
await expect(restorePrepackArtifacts(root)).rejects.toThrow("changed since the backup");
|
||||
expect(existsSync(receiptPath)).toBe(true);
|
||||
expect(existsSync(changelogBackupPath)).toBe(true);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
|
||||
|
||||
writeFileSync(path.join(root, "CHANGELOG.md"), packagedChangelog);
|
||||
await expect(restorePrepackArtifacts(root)).resolves.toBeUndefined();
|
||||
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
|
||||
expect(readFileSync(mapPath, "utf8")).toBe(stub);
|
||||
expect(existsSync(changelogBackupPath)).toBe(false);
|
||||
expect(existsSync(receiptPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user