From 3a72d2d6deb6979db82b4f7385e1371b47fe27fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 00:03:01 -0700 Subject: [PATCH] fix(config): split config doc baseline coverage --- src/config/doc-baseline.integration.test.ts | 138 +++++++++ src/config/doc-baseline.test.ts | 144 +--------- src/config/doc-baseline.ts | 300 ++++++-------------- test/fixtures/test-parallel.behavior.json | 4 +- 4 files changed, 235 insertions(+), 351 deletions(-) create mode 100644 src/config/doc-baseline.integration.test.ts diff --git a/src/config/doc-baseline.integration.test.ts b/src/config/doc-baseline.integration.test.ts new file mode 100644 index 00000000000..1cb81623889 --- /dev/null +++ b/src/config/doc-baseline.integration.test.ts @@ -0,0 +1,138 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildConfigDocBaseline, + renderConfigDocBaselineStatefile, + writeConfigDocBaselineStatefile, +} from "./doc-baseline.js"; + +describe("config doc baseline integration", () => { + const tempRoots: string[] = []; + let sharedBaselinePromise: Promise>> | null = + null; + let sharedRenderedPromise: Promise< + Awaited> + > | null = null; + + function getSharedBaseline() { + sharedBaselinePromise ??= buildConfigDocBaseline(); + return sharedBaselinePromise; + } + + function getSharedRendered() { + sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline()); + return sharedRenderedPromise; + } + + afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map(async (tempRoot) => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }), + ); + }); + + it("is deterministic across repeated runs", async () => { + const first = await renderConfigDocBaselineStatefile(); + const second = await renderConfigDocBaselineStatefile(); + + expect(second.json).toBe(first.json); + expect(second.jsonl).toBe(first.jsonl); + }); + + it("includes core, channel, and plugin config metadata", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("gateway.auth.token")).toMatchObject({ + kind: "core", + sensitive: true, + }); + expect(byPath.get("channels.telegram.botToken")).toMatchObject({ + kind: "channel", + sensitive: true, + }); + expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({ + kind: "plugin", + sensitive: true, + }); + }); + + it("preserves help text and tags from merged schema hints", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + const tokenEntry = byPath.get("gateway.auth.token"); + + expect(tokenEntry?.help).toContain("gateway access"); + expect(tokenEntry?.tags).toContain("auth"); + expect(tokenEntry?.tags).toContain("security"); + }); + + it("matches array help hints that still use [] notation", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({ + help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"), + sensitive: false, + }); + }); + + it("walks union branches for nested config keys", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("bindings.*")).toMatchObject({ + hasChildren: true, + }); + expect(byPath.get("bindings.*.type")).toBeDefined(); + expect(byPath.get("bindings.*.match.channel")).toBeDefined(); + expect(byPath.get("bindings.*.match.peer.id")).toBeDefined(); + }); + + it("supports check mode for stale generated artifacts", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-")); + tempRoots.push(tempRoot); + const rendered = getSharedRendered(); + + const initial = await writeConfigDocBaselineStatefile({ + repoRoot: tempRoot, + jsonPath: "docs/.generated/config-baseline.json", + statefilePath: "docs/.generated/config-baseline.jsonl", + rendered, + }); + expect(initial.wrote).toBe(true); + + const current = await writeConfigDocBaselineStatefile({ + repoRoot: tempRoot, + jsonPath: "docs/.generated/config-baseline.json", + statefilePath: "docs/.generated/config-baseline.jsonl", + check: true, + rendered, + }); + expect(current.changed).toBe(false); + + await fs.writeFile( + path.join(tempRoot, "docs/.generated/config-baseline.json"), + '{"generatedBy":"broken","entries":[]}\n', + "utf8", + ); + await fs.writeFile( + path.join(tempRoot, "docs/.generated/config-baseline.jsonl"), + '{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n', + "utf8", + ); + + const stale = await writeConfigDocBaselineStatefile({ + repoRoot: tempRoot, + jsonPath: "docs/.generated/config-baseline.json", + statefilePath: "docs/.generated/config-baseline.jsonl", + check: true, + rendered, + }); + expect(stale.changed).toBe(true); + expect(stale.wrote).toBe(false); + }); +}); diff --git a/src/config/doc-baseline.test.ts b/src/config/doc-baseline.test.ts index a1e670401b1..a86a230bcfc 100644 --- a/src/config/doc-baseline.test.ts +++ b/src/config/doc-baseline.test.ts @@ -1,107 +1,17 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - buildConfigDocBaseline, collectConfigDocBaselineEntries, dedupeConfigDocBaselineEntries, normalizeConfigDocBaselineHelpPath, - renderConfigDocBaselineStatefile, - writeConfigDocBaselineStatefile, } from "./doc-baseline.js"; describe("config doc baseline", () => { - const tempRoots: string[] = []; - let sharedBaselinePromise: Promise>> | null = - null; - let sharedRenderedPromise: Promise< - Awaited> - > | null = null; - - function getSharedBaseline() { - sharedBaselinePromise ??= buildConfigDocBaseline(); - return sharedBaselinePromise; - } - - function getSharedRendered() { - sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline()); - return sharedRenderedPromise; - } - - afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map(async (tempRoot) => { - await fs.rm(tempRoot, { recursive: true, force: true }); - }), - ); - }); - - it("is deterministic across repeated runs", async () => { - const first = await renderConfigDocBaselineStatefile(); - const second = await renderConfigDocBaselineStatefile(); - - expect(second.json).toBe(first.json); - expect(second.jsonl).toBe(first.jsonl); - }); - it("normalizes array and record paths to wildcard form", async () => { - const baseline = await getSharedBaseline(); - const paths = new Set(baseline.entries.map((entry) => entry.path)); - - expect(paths.has("session.sendPolicy.rules.*.match.keyPrefix")).toBe(true); - expect(paths.has("env.*")).toBe(true); expect(normalizeConfigDocBaselineHelpPath("agents.list[].skills")).toBe("agents.list.*.skills"); - }); - - it("includes core, channel, and plugin config metadata", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - - expect(byPath.get("gateway.auth.token")).toMatchObject({ - kind: "core", - sensitive: true, - }); - expect(byPath.get("channels.telegram.botToken")).toMatchObject({ - kind: "channel", - sensitive: true, - }); - expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({ - kind: "plugin", - sensitive: true, - }); - }); - - it("preserves help text and tags from merged schema hints", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - const tokenEntry = byPath.get("gateway.auth.token"); - - expect(tokenEntry?.help).toContain("gateway access"); - expect(tokenEntry?.tags).toContain("auth"); - expect(tokenEntry?.tags).toContain("security"); - }); - - it("matches array help hints that still use [] notation", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - - expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({ - help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"), - sensitive: false, - }); - }); - - it("walks union branches for nested config keys", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - - expect(byPath.get("bindings.*")).toMatchObject({ - hasChildren: true, - }); - expect(byPath.get("bindings.*.type")).toBeDefined(); - expect(byPath.get("bindings.*.match.channel")).toBeDefined(); - expect(byPath.get("bindings.*.match.peer.id")).toBeDefined(); + expect(normalizeConfigDocBaselineHelpPath("session.sendPolicy.rules[0].match.keyPrefix")).toBe( + "session.sendPolicy.rules.*.match.keyPrefix", + ); + expect(normalizeConfigDocBaselineHelpPath(".env.*.")).toBe("env.*"); }); it("merges tuple item metadata instead of dropping earlier entries", () => { @@ -132,48 +42,4 @@ describe("config doc baseline", () => { expect(tupleEntry?.enumValues).toEqual(expect.arrayContaining([42, "alpha"])); expect(tupleEntry?.enumValues).toHaveLength(2); }); - - it("supports check mode for stale generated artifacts", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-")); - tempRoots.push(tempRoot); - const rendered = getSharedRendered(); - - const initial = await writeConfigDocBaselineStatefile({ - repoRoot: tempRoot, - jsonPath: "docs/.generated/config-baseline.json", - statefilePath: "docs/.generated/config-baseline.jsonl", - rendered, - }); - expect(initial.wrote).toBe(true); - - const current = await writeConfigDocBaselineStatefile({ - repoRoot: tempRoot, - jsonPath: "docs/.generated/config-baseline.json", - statefilePath: "docs/.generated/config-baseline.jsonl", - check: true, - rendered, - }); - expect(current.changed).toBe(false); - - await fs.writeFile( - path.join(tempRoot, "docs/.generated/config-baseline.json"), - '{"generatedBy":"broken","entries":[]}\n', - "utf8", - ); - await fs.writeFile( - path.join(tempRoot, "docs/.generated/config-baseline.jsonl"), - '{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n', - "utf8", - ); - - const stale = await writeConfigDocBaselineStatefile({ - repoRoot: tempRoot, - jsonPath: "docs/.generated/config-baseline.json", - statefilePath: "docs/.generated/config-baseline.jsonl", - check: true, - rendered, - }); - expect(stale.changed).toBe(true); - expect(stale.wrote).toBe(false); - }); }); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 525c91bb521..1603fa3dd1b 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -1,13 +1,10 @@ -import { spawnSync } from "node:child_process"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { ChannelPlugin } from "../channels/plugins/index.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { FIELD_HELP } from "./schema.help.js"; -import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; +import type { ConfigSchemaResponse } from "./schema.js"; import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -28,12 +25,6 @@ type JsonSchemaObject = JsonSchemaNode & { oneOf?: JsonSchemaObject[]; }; -type PackageChannelMetadata = { - id: string; - label: string; - blurb?: string; -}; - type ChannelSurfaceMetadata = { id: string; label: string; @@ -277,191 +268,11 @@ function resolveFirstExistingPath(candidates: string[]): string | null { return null; } -function loadPackageChannelMetadata(rootDir: string): PackageChannelMetadata | null { - try { - const packageJson = JSON.parse( - fsSync.readFileSync(path.join(rootDir, "package.json"), "utf8"), - ) as { - openclaw?: { - channel?: { - id?: unknown; - label?: unknown; - blurb?: unknown; - }; - }; - }; - const channel = packageJson.openclaw?.channel; - if (!channel) { - return null; - } - const id = typeof channel.id === "string" ? channel.id.trim() : ""; - const label = typeof channel.label === "string" ? channel.label.trim() : ""; - const blurb = typeof channel.blurb === "string" ? channel.blurb.trim() : ""; - if (!id || !label) { - return null; - } - return { - id, - label, - ...(blurb ? { blurb } : {}), - }; - } catch { - return null; - } -} - -function isChannelPlugin(value: unknown): value is ChannelPlugin { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as { id?: unknown; meta?: unknown; capabilities?: unknown }; - return typeof candidate.id === "string" && typeof candidate.meta === "object"; -} - -function resolveSetupChannelPlugin(value: unknown): ChannelPlugin | null { - if (!value || typeof value !== "object") { - return null; - } - const candidate = value as { plugin?: unknown }; - return isChannelPlugin(candidate.plugin) ? candidate.plugin : null; -} - -async function importChannelPluginModule(rootDir: string): Promise { - logConfigDocBaselineDebug(`resolve channel module ${rootDir}`); - const modulePath = resolveFirstExistingPath([ - path.join(rootDir, "setup-entry.ts"), - path.join(rootDir, "setup-entry.js"), - path.join(rootDir, "setup-entry.mts"), - path.join(rootDir, "setup-entry.mjs"), - path.join(rootDir, "src", "channel.ts"), - path.join(rootDir, "src", "channel.js"), - path.join(rootDir, "src", "plugin.ts"), - path.join(rootDir, "src", "plugin.js"), - path.join(rootDir, "src", "index.ts"), - path.join(rootDir, "src", "index.js"), - path.join(rootDir, "src", "channel.mts"), - path.join(rootDir, "src", "channel.mjs"), - path.join(rootDir, "src", "plugin.mts"), - path.join(rootDir, "src", "plugin.mjs"), - ]); - if (!modulePath) { - throw new Error(`channel source not found under ${rootDir}`); - } - - logConfigDocBaselineDebug(`import channel module ${modulePath}`); - const imported = (await import(modulePath)) as Record; - logConfigDocBaselineDebug(`imported channel module ${modulePath}`); - for (const value of Object.values(imported)) { - if (isChannelPlugin(value)) { - logConfigDocBaselineDebug(`resolved channel export ${modulePath}`); - return value; - } - const setupPlugin = resolveSetupChannelPlugin(value); - if (setupPlugin) { - logConfigDocBaselineDebug(`resolved setup channel export ${modulePath}`); - return setupPlugin; - } - if (typeof value === "function" && value.length === 0) { - const resolved = value(); - if (isChannelPlugin(resolved)) { - logConfigDocBaselineDebug(`resolved channel factory ${modulePath}`); - return resolved; - } - } - } - - throw new Error(`channel plugin export not found in ${modulePath}`); -} - -async function importChannelSurfaceMetadata( - rootDir: string, - repoRoot: string, - env: NodeJS.ProcessEnv, -): Promise { - logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); - const packageMetadata = loadPackageChannelMetadata(rootDir); - if (!packageMetadata) { - logConfigDocBaselineDebug(`missing package channel metadata ${rootDir}`); - return null; - } - - const modulePath = resolveFirstExistingPath([ - path.join(rootDir, "src", "config-schema.ts"), - path.join(rootDir, "src", "config-schema.js"), - path.join(rootDir, "src", "config-schema.mts"), - path.join(rootDir, "src", "config-schema.mjs"), - ]); - if (!modulePath) { - logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`); - return null; - } - - logConfigDocBaselineDebug(`import channel config schema ${modulePath}`); - try { - logConfigDocBaselineDebug(`spawn channel config schema subprocess ${modulePath}`); - const result = spawnSync( - process.execPath, - [ - "--import", - "tsx", - path.join(repoRoot, "scripts", "load-channel-config-surface.ts"), - modulePath, - ], - { - cwd: repoRoot, - encoding: "utf8", - env, - timeout: 15_000, - maxBuffer: 10 * 1024 * 1024, - }, - ); - if (result.status !== 0 || result.error) { - throw result.error ?? new Error(result.stderr || `child exited with status ${result.status}`); - } - logConfigDocBaselineDebug(`completed channel config schema subprocess ${modulePath}`); - const configSchema = JSON.parse(result.stdout) as { - schema: Record; - uiHints?: ConfigSchemaResponse["uiHints"]; - }; - return { - id: packageMetadata.id, - label: packageMetadata.label, - description: packageMetadata.blurb, - configSchema: configSchema.schema, - configUiHints: configSchema.uiHints, - }; - } catch (error) { - logConfigDocBaselineDebug( - `channel config schema subprocess failed for ${modulePath}: ${String(error)}`, - ); - return null; - } -} - -async function loadChannelSurfaceMetadata( - rootDir: string, - repoRoot: string, - env: NodeJS.ProcessEnv, -): Promise { - logConfigDocBaselineDebug(`load channel surface ${rootDir}`); - const configSurface = await importChannelSurfaceMetadata(rootDir, repoRoot, env); - if (configSurface) { - logConfigDocBaselineDebug(`resolved channel config surface ${rootDir}`); - return configSurface; - } - - logConfigDocBaselineDebug(`fallback to channel plugin import ${rootDir}`); - const plugin = await importChannelPluginModule(rootDir); - return { - id: plugin.id, - label: plugin.meta.label, - description: plugin.meta.blurb, - configSchema: plugin.configSchema?.schema, - configUiHints: plugin.configSchema?.uiHints, - }; -} - async function loadBundledConfigSchemaResponse(): Promise { + const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([ + import("../plugins/manifest-registry.js"), + import("./schema.js"), + ]); const repoRoot = resolveRepoRoot(); const env = { ...process.env, @@ -479,22 +290,49 @@ async function loadBundledConfigSchemaResponse(): Promise const bundledChannelPlugins = manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && plugin.channels.length > 0, ); - const loadChannelsSequentiallyForDebug = process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1"; - const channelPlugins = loadChannelsSequentiallyForDebug - ? await bundledChannelPlugins.reduce>( - async (promise, plugin) => { - const loaded = await promise; - loaded.push(await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env)); - return loaded; - }, - Promise.resolve([]), - ) - : await Promise.all( - bundledChannelPlugins.map( - async (plugin) => await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env), - ), - ); - logConfigDocBaselineDebug(`imported ${channelPlugins.length} bundled channel plugins`); + const channelPlugins = + process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1" + ? await bundledChannelPlugins.reduce>( + async (promise, plugin) => { + const loaded = await promise; + loaded.push( + (await loadChannelSurfaceMetadata( + plugin.rootDir, + plugin.id, + plugin.name ?? plugin.id, + repoRoot, + )) ?? { + id: plugin.id, + label: plugin.name ?? plugin.id, + description: plugin.description, + configSchema: plugin.configSchema, + configUiHints: plugin.configUiHints, + }, + ); + return loaded; + }, + Promise.resolve([]), + ) + : await Promise.all( + bundledChannelPlugins.map( + async (plugin) => + (await loadChannelSurfaceMetadata( + plugin.rootDir, + plugin.id, + plugin.name ?? plugin.id, + repoRoot, + )) ?? { + id: plugin.id, + label: plugin.name ?? plugin.id, + description: plugin.description, + configSchema: plugin.configSchema, + configUiHints: plugin.configUiHints, + }, + ), + ); + logConfigDocBaselineDebug( + `loaded ${channelPlugins.length} bundled channel entries from channel surfaces`, + ); return buildConfigSchema({ cache: false, @@ -517,6 +355,48 @@ async function loadBundledConfigSchemaResponse(): Promise }); } +async function loadChannelSurfaceMetadata( + rootDir: string, + id: string, + label: string, + repoRoot: string, +): Promise { + logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); + const modulePath = resolveFirstExistingPath([ + path.join(rootDir, "src", "config-schema.ts"), + path.join(rootDir, "src", "config-schema.js"), + path.join(rootDir, "src", "config-schema.mts"), + path.join(rootDir, "src", "config-schema.mjs"), + ]); + if (!modulePath) { + logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`); + return null; + } + + logConfigDocBaselineDebug(`import channel config schema ${modulePath}`); + try { + const { loadChannelConfigSurfaceModule } = + await import("../../scripts/load-channel-config-surface.ts"); + const configSurface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot }); + if (!configSurface) { + logConfigDocBaselineDebug(`channel config schema export missing ${modulePath}`); + return null; + } + logConfigDocBaselineDebug(`completed channel config schema import ${modulePath}`); + return { + id, + label, + configSchema: configSurface.schema, + configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined, + }; + } catch (error) { + logConfigDocBaselineDebug( + `channel config schema import failed for ${modulePath}: ${String(error)}`, + ); + return null; + } +} + export function collectConfigDocBaselineEntries( schema: JsonSchemaObject, uiHints: ConfigSchemaResponse["uiHints"], diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index a9e0d95569f..2de992a45d5 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -14,7 +14,7 @@ "reason": "Mutates process.cwd() and core loader seams." }, { - "file": "src/config/doc-baseline.test.ts", + "file": "src/config/doc-baseline.integration.test.ts", "reason": "Rebuilds bundled config baselines through many channel schema subprocesses; keep out of the shared lane." }, { @@ -28,7 +28,7 @@ "reason": "Clean in isolation, but can hang after sharing the broad lane." }, { - "file": "src/config/doc-baseline.test.ts", + "file": "src/config/doc-baseline.integration.test.ts", "reason": "Builds the full bundled config schema graph and is safer outside the shared unit-fast heap." }, {