docs: document shared test helpers

This commit is contained in:
Peter Steinberger
2026-06-04 01:48:32 -04:00
parent d8b5e22e8b
commit 86150a3e51
27 changed files with 121 additions and 0 deletions

View File

@@ -4,13 +4,17 @@ import { findTaskByRunId, resetTaskRegistryForTests } from "../../src/tasks/task
import { withTempDir } from "../../src/test-helpers/temp-dir.js";
import { installInMemoryTaskRegistryRuntime } from "../../src/test-utils/task-registry-runtime.js";
// Shared ACP manager task registry setup for tests.
export { findTaskByRunId };
/** Reset task and task-flow registries without persisting state. */
export function resetAcpManagerTaskStateForTests(): void {
resetTaskRegistryForTests({ persist: false });
resetTaskFlowRegistryForTests({ persist: false });
}
/** Run a test with isolated ACP manager task state rooted in a temp dir. */
export async function withAcpManagerTaskStateDir(
run: (root: string) => Promise<void>,
): Promise<void> {
@@ -37,6 +41,7 @@ export async function withAcpManagerTaskStateDir(
});
}
/** Return a task by run id or fail the test with a clear message. */
export function requireTaskByRunId(runId: string) {
const task = findTaskByRunId(runId);
if (!task) {

View File

@@ -27,6 +27,8 @@ import {
CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR,
} from "./prompt-snapshot-paths.js";
// Builds Codex happy-path prompt snapshot fixtures for agent prompt regression tests.
export { CODEX_MODEL_PROMPT_FIXTURE_DIR, CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR };
const WORKSPACE_DIR = "/tmp/openclaw-happy-path/workspace";
@@ -123,6 +125,7 @@ const CODEX_TEST_API_MODULE_ID = resolveRelativeBundledPluginPublicModuleId({
artifactBasename: "test-api.js",
});
/** Load the Codex public test API without hardcoding plugin-private paths. */
async function loadCodexPromptSnapshotApi(): Promise<CodexPromptSnapshotApi> {
return (await import(CODEX_TEST_API_MODULE_ID)) as CodexPromptSnapshotApi;
}
@@ -917,6 +920,7 @@ function renderReadme(scenarios: PromptScenario[]): string {
].join("\n");
}
/** Build all Codex happy-path prompt snapshot files without writing them. */
export async function createHappyPathPromptSnapshotFiles(): Promise<PromptSnapshotFile[]> {
const codexApi = await loadCodexPromptSnapshotApi();
const scenarios = createScenarios(codexApi);

View File

@@ -26,6 +26,9 @@ import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../src/test-helpers/workspace.js";
// Prompt composition scenarios for system/body prompt stability tests.
/** One turn in a prompt composition scenario. */
export type PromptScenarioTurn = {
id: string;
label: string;
@@ -34,6 +37,7 @@ export type PromptScenarioTurn = {
notes: string[];
};
/** Multi-turn prompt composition scenario fixture. */
export type PromptScenario = {
scenario: string;
focus: string;
@@ -733,6 +737,7 @@ async function createMaintenanceScenario(workspaceDir: string): Promise<PromptSc
};
}
/** Create a temp workspace with prompt composition context files. */
export async function createWorkspaceWithPromptCompositionFiles(): Promise<string> {
const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-");
await writeWorkspaceFile({
@@ -761,6 +766,7 @@ export async function createWorkspaceWithPromptCompositionFiles(): Promise<strin
return workspaceDir;
}
/** Create all prompt composition scenarios plus cleanup handles. */
export async function createPromptCompositionScenarios(): Promise<{
workspaceDir: string;
warningWorkspaceDir: string;

View File

@@ -1,4 +1,8 @@
// Shared prompt snapshot fixture directories.
/** Codex runtime happy-path prompt snapshot fixture directory. */
export const CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR =
"test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path";
/** Codex model prompt fixture directory. */
export const CODEX_MODEL_PROMPT_FIXTURE_DIR =
"test/fixtures/agents/prompt-snapshots/codex-model-catalog";

View File

@@ -1,8 +1,12 @@
// Shared transport parameter contract fixtures for GPT-5 model tests.
/** Expected OpenAI GPT-5 transport defaults. */
export const OPENAI_GPT5_TRANSPORT_DEFAULTS = {
parallel_tool_calls: true,
text_verbosity: "low",
} as const;
/** OpenAI GPT-5 cases that should receive GPT transport defaults. */
export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [
{
provider: "openai",
@@ -14,11 +18,13 @@ export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [
},
] as const;
/** Non-OpenAI GPT-5 case that should not receive OpenAI defaults. */
export const NON_OPENAI_GPT5_TRANSPORT_CASE = {
provider: "openrouter",
modelId: "gpt-5.4",
} as const;
/** Payload APIs that support parallel_tool_calls in GPT tests. */
export const GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS = [
"openai-completions",
"openai-responses",
@@ -26,6 +32,7 @@ export const GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS = [
"azure-openai-responses",
] as const;
/** Payload APIs unrelated to GPT parallel tool call defaults. */
export const UNRELATED_TOOL_CALLS_PAYLOAD_APIS = [
"anthropic-messages",
"google-generative-ai",

View File

@@ -6,9 +6,12 @@ import { makeTempWorkspace } from "../../src/test-helpers/workspace.js";
import { captureEnv } from "../../src/test-utils/env.js";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
// Shared auth wizard test helpers for runtime/env setup.
const noopAsync = async () => {};
const noop = () => {};
/** Create a RuntimeEnv whose exit method throws for assertions. */
export function createExitThrowingRuntime(): RuntimeEnv {
return {
log: vi.fn(),
@@ -19,6 +22,7 @@ export function createExitThrowingRuntime(): RuntimeEnv {
};
}
/** Create a WizardPrompter with default mock answers and caller overrides. */
export function createWizardPrompter(
overrides: Partial<WizardPrompter>,
options?: { defaultSelect?: string },
@@ -36,6 +40,7 @@ export function createWizardPrompter(
};
}
/** Create isolated auth state and agent directories for auth tests. */
export async function setupAuthTestEnv(
prefix = "openclaw-auth-",
options?: { agentSubdir?: string },
@@ -56,6 +61,7 @@ type AuthTestLifecycle = {
cleanup: () => Promise<void>;
};
/** Capture env and track one state dir for cleanup. */
export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle {
const envSnapshot = captureEnv(envKeys);
let stateDir: string | null = null;
@@ -73,6 +79,7 @@ export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle {
};
}
/** Return OPENCLAW_AGENT_DIR or fail the test clearly. */
export function requireOpenClawAgentDir(): string {
const agentDir = process.env.OPENCLAW_AGENT_DIR;
if (!agentDir) {
@@ -81,10 +88,12 @@ export function requireOpenClawAgentDir(): string {
return agentDir;
}
/** Resolve the auth profile JSON path for an agent directory. */
function authProfilePathForAgent(agentDir: string): string {
return path.join(agentDir, "auth-profiles.json");
}
/** Read and parse auth profiles for an agent directory. */
export async function readAuthProfilesForAgent<T>(agentDir: string): Promise<T> {
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
return JSON.parse(raw) as T;

View File

@@ -2,6 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Bundled browser plugin fixture used by plugin/package tests.
const BROWSER_FIXTURE_MANIFEST = {
id: "browser",
enabledByDefault: true,
@@ -52,6 +54,7 @@ const BROWSER_FIXTURE_ENTRY = `module.exports = {
},
};`;
/** Create a temporary bundled browser plugin fixture and cleanup callback. */
export function createBundledBrowserPluginFixture(): { rootDir: string; cleanup: () => void } {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-browser-bundled-"));
const pluginDir = path.join(rootDir, "browser");

View File

@@ -1,3 +1,6 @@
// Bundled runtime sidecar paths that package/build tests expect to exist.
/** Runtime sidecar files shipped with bundled channel plugins. */
export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
"dist/extensions/discord/runtime-api.js",
"dist/extensions/telegram/runtime-api.js",

View File

@@ -1,3 +1,6 @@
// In-memory stdout/stderr capture helper for command tests.
/** Create a minimal IO object plus readers for captured output. */
export function createCapturedIo() {
let stdout = "";
let stderr = "";

View File

@@ -5,6 +5,8 @@ import type {
} from "../../../src/channels/plugins/types.plugin.js";
import { listBundledPluginMetadata } from "../../../src/plugins/bundled-plugin-metadata.js";
// Shared bundled channel config runtime maps for config contract tests.
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
type BundledChannelPluginShape = {
@@ -18,6 +20,7 @@ type BundledChannelMaps = {
let cachedBundledChannelMaps: BundledChannelMaps | undefined;
/** Build runtime/config maps from public bundled channel plugin metadata. */
function buildBundledChannelMaps(
plugins: readonly BundledChannelPluginShape[],
): BundledChannelMaps {
@@ -61,6 +64,7 @@ function buildBundledChannelMaps(
return { runtimeMap, configSchemaMap };
}
/** Read bundled channel plugin surfaces when available in this test process. */
function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | undefined {
try {
if (typeof bundledChannelModule.listBundledChannelPlugins !== "function") {
@@ -76,6 +80,7 @@ function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | und
}
}
/** Return cached maps when live bundled plugin surfaces were available. */
function getBundledChannelMaps(): BundledChannelMaps {
const plugins = readBundledChannelPlugins();
if (plugins && cachedBundledChannelMaps) {
@@ -89,10 +94,12 @@ function getBundledChannelMaps(): BundledChannelMaps {
return maps;
}
/** Return runtime config schemas keyed by bundled channel id. */
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
return getBundledChannelMaps().runtimeMap;
}
/** Return channel config schemas keyed by bundled channel id. */
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
return getBundledChannelMaps().configSchemaMap;
}

View File

@@ -3,6 +3,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { computeBaseConfigSchemaResponse } from "../../../src/config/schema-base.js";
// Config honor audit helpers that compare schema keys with proof inventories.
/** Inventory row describing where one config key is declared, merged, consumed, and tested. */
export type ConfigHonorInventoryRow = {
key: string;
schemaPaths: string[];
@@ -22,6 +25,7 @@ type ConfigHonorProofKey =
| "reloadPaths"
| "testPaths";
/** Result of auditing one config honor inventory. */
export type ConfigHonorAuditResult = {
schemaKeys: string[];
missingKeys: string[];
@@ -39,6 +43,7 @@ const BASE_CONFIG_SCHEMA = computeBaseConfigSchemaResponse({
generatedAt: "2026-05-05T00:00:00.000Z",
});
/** Return true when a dotted schema path exists in the generated base config schema. */
function hasSchemaPath(schemaPath: string): boolean {
const segments = schemaPath.split(".");
let current: unknown = BASE_CONFIG_SCHEMA.schema;
@@ -63,6 +68,7 @@ function hasSchemaPath(schemaPath: string): boolean {
return true;
}
/** List leaf schema keys for the requested config prefixes. */
export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] {
const keys = new Set<string>();
for (const prefix of prefixes) {
@@ -90,6 +96,7 @@ export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] {
return [...keys].toSorted();
}
/** Audit an inventory against schema keys, proof paths, and file existence. */
export function auditConfigHonorInventory(params: {
prefixes: string[];
rows: ConfigHonorInventoryRow[];

View File

@@ -1,10 +1,14 @@
import type { ConfigHonorInventoryRow } from "./config-honor-audit.js";
// Inventory of heartbeat config keys and the proof paths that should honor them.
/** Config prefixes audited for heartbeat key coverage. */
export const HEARTBEAT_CONFIG_PREFIXES = [
"agents.defaults.heartbeat",
"agents.list.*.heartbeat",
] as const;
/** Heartbeat config honor inventory consumed by config audit tests. */
export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
{
key: "every",

View File

@@ -1,9 +1,13 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
// Test helper for unwrapping gateway config.get response shapes.
/** Narrow unknown payloads to plain records for fixture parsing. */
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
/** Unwrap current and legacy remote config snapshot envelopes. */
export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig {
const rawObj = asRecord(raw);
const resolved = asRecord(rawObj.resolved);

View File

@@ -1,5 +1,8 @@
import type { GatewayConnectionDetails } from "../../../src/gateway/call.js";
// Test helper for deciding when Android node policy config should be fetched remotely.
/** Return true when gateway details represent a remote node, not local loopback. */
export function shouldFetchRemotePolicyConfig(details: GatewayConnectionDetails): boolean {
return details.urlSource !== "local loopback";
}

View File

@@ -10,6 +10,8 @@ import {
} from "../../../src/infra/outbound/send-deps.js";
import { createOutboundTestPlugin } from "../../../src/test-utils/channel-plugins.js";
// Channel plugin fixtures used by heartbeat runner tests.
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
type HeartbeatSendFn = (
to: string,
@@ -17,6 +19,7 @@ type HeartbeatSendFn = (
opts?: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
/** Create an outbound adapter that routes through heartbeat send deps. */
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
return {
deliveryMode: "direct",
@@ -48,6 +51,7 @@ function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): Chan
};
}
/** Create a channel plugin fixture with heartbeat/outbound behavior. */
function createHeartbeatChannelPlugin(params: {
id: HeartbeatSendChannelId;
label: string;
@@ -67,12 +71,14 @@ function createHeartbeatChannelPlugin(params: {
};
}
/** Slack heartbeat channel fixture. */
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
});
/** Telegram heartbeat channel fixture with thread preservation. */
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
id: "telegram",
label: "Telegram",
@@ -82,6 +88,7 @@ export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
},
});
/** WhatsApp heartbeat channel fixture with readiness checks. */
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
id: "whatsapp",
label: "WhatsApp",

View File

@@ -1,12 +1,15 @@
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
// Public-surface loader for bundled media provider plugin tests.
type BundledPluginEntryModule = {
default: {
register(api: OpenClawPluginApi): void;
};
};
/** Load a bundled provider plugin entrypoint through the public surface helper. */
export function loadBundledProviderPlugin(pluginId: string): BundledPluginEntryModule["default"] {
return loadBundledPluginPublicSurfaceSync<BundledPluginEntryModule>({
pluginId,

View File

@@ -5,6 +5,8 @@ import type { MusicGenerationProvider } from "../../../src/music-generation/type
import type { VideoGenerationProvider } from "../../../src/video-generation/types.js";
import { resetGenerationRuntimeMocks } from "./runtime-test-mocks.js";
// Shared Vitest module mocks for image, music, and video generation runtimes.
type ModelRef = { provider: string; model: string };
const mediaRuntimeMocks = vi.hoisted(() => {
@@ -128,10 +130,12 @@ vi.mock("../../../src/video-generation/provider-registry.js", () => ({
listVideoGenerationProviders: mediaRuntimeMocks.listVideoGenerationProviders,
}));
/** Return the hoisted shared media generation runtime mocks. */
export function getMediaGenerationRuntimeMocks() {
return mediaRuntimeMocks;
}
/** Reset image generation runtime mocks to default empty-provider behavior. */
export function resetImageGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
@@ -142,6 +146,7 @@ export function resetImageGenerationRuntimeMocks(): void {
});
}
/** Reset music generation runtime mocks to default empty-provider behavior. */
export function resetMusicGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
@@ -152,6 +157,7 @@ export function resetMusicGenerationRuntimeMocks(): void {
});
}
/** Reset video generation runtime mocks to default empty-provider behavior. */
export function resetVideoGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
@@ -162,6 +168,7 @@ export function resetVideoGenerationRuntimeMocks(): void {
});
}
/** Reset shared auth/failover/logger mocks used by all media generation runtimes. */
function resetSharedRuntimeImportMocks(): void {
mediaRuntimeMocks.ensureAuthProfileStore.mockReset();
mediaRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });

View File

@@ -1,3 +1,5 @@
// Shared mock reset contract for generated-media runtime tests.
type ClearableMock = {
mockClear(): unknown;
};
@@ -10,6 +12,7 @@ type ResettableReturnMock = ResettableMock & {
mockReturnValue(value: unknown): unknown;
};
/** Common mock shape shared by image, music, and video generation runtime tests. */
export type GenerationRuntimeMocks = {
createSubsystemLogger: ClearableMock;
describeFailoverError: ResettableMock;
@@ -26,6 +29,7 @@ export type GenerationRuntimeMocks = {
warn: ResettableMock;
};
/** Reset generated-media runtime mocks to default no-provider behavior. */
export function resetGenerationRuntimeMocks(mocks: GenerationRuntimeMocks): void {
mocks.createSubsystemLogger.mockClear();
mocks.describeFailoverError.mockReset();

View File

@@ -1,5 +1,8 @@
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
// Snapshot text normalization for terminal output tests.
/** Strip ANSI, normalize line endings, ellipses, and emoji/surrogate pairs. */
export function normalizeTestText(input: string): string {
return stripAnsi(input)
.replaceAll("\r\n", "\n")

View File

@@ -1,5 +1,8 @@
import path from "node:path";
// Cross-platform path containment helper for tests.
/** Return true when target is equal to or inside base, with Windows case folding. */
export function isPathWithinBase(base: string, target: string): boolean {
if (process.platform === "win32") {
const normalizedBase = path.win32.normalize(path.win32.resolve(base));

View File

@@ -2,6 +2,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Temporary JSON pattern file helper for config/pattern tests.
/** Create a helper that writes JSON pattern files and cleans their temp dirs. */
export function createPatternFileHelper(prefix: string) {
const tempDirs = new Set<string>();

View File

@@ -1,5 +1,7 @@
"use strict";
// Universal CommonJS plugin-sdk stub for tests that only need import shape compatibility.
const stub = new Proxy(
function pluginSdkStub() {
return stub;

View File

@@ -1,10 +1,14 @@
import { sleep } from "../../src/utils.js";
// Polling helper for tests that wait on async state.
/** Polling timeout and interval options. */
export type PollOptions = {
timeoutMs?: number;
intervalMs?: number;
};
/** Poll until fn returns a non-nullish value or timeout elapses. */
export async function pollUntil<T>(
fn: () => Promise<T | null | undefined>,
opts: PollOptions = {},

View File

@@ -2,6 +2,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Synchronous temporary directory helpers for tests.
/** Create a temp dir and register it in an array or set for cleanup. */
export function makeTempDir(tempDirs: string[] | Set<string>, prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
if (Array.isArray(tempDirs)) {
@@ -12,6 +15,7 @@ export function makeTempDir(tempDirs: string[] | Set<string>, prefix: string): s
return dir;
}
/** Remove all tracked temporary directories and clear the tracker. */
export function cleanupTempDirs(tempDirs: string[] | Set<string>): void {
const dirs = Array.isArray(tempDirs) ? tempDirs.splice(0) : [...tempDirs];
for (const dir of dirs) {

View File

@@ -2,17 +2,22 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Synchronous temporary repository helpers for tests.
/** Create and track a temporary repo root. */
export function makeTempRepoRoot(tempDirs: string[], prefix: string): string {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(repoRoot);
return repoRoot;
}
/** Write formatted JSON to a path, creating parent directories. */
export function writeJsonFile(filePath: string, value: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
/** Remove all tracked temporary directories. */
export function cleanupTempDirs(tempDirs: string[]): void {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });

View File

@@ -1,5 +1,8 @@
import path from "node:path";
// Path normalization helpers for Vitest config snapshot assertions.
/** Convert absolute paths to cwd-relative POSIX-style paths. */
export function normalizeConfigPath(value: unknown): unknown {
if (typeof value !== "string" || !path.isAbsolute(value)) {
return value;
@@ -7,6 +10,7 @@ export function normalizeConfigPath(value: unknown): unknown {
return path.relative(process.cwd(), value).split(path.sep).join("/");
}
/** Normalize one or many config path values. */
export function normalizeConfigPaths(
values: readonly unknown[] | string | undefined,
): unknown[] | undefined {

View File

@@ -1,6 +1,9 @@
import { vi } from "vitest";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
// Vitest mock prompter for wizard tests.
/** Create a WizardPrompter with default mocked responses and optional overrides. */
export function createWizardPrompter(overrides?: Partial<WizardPrompter>): WizardPrompter {
const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"];
return {