From 9eedf1d58bfe5081493218e528be817e3a9e5e5d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 10:37:57 -0700 Subject: [PATCH 1/9] Add debug logging mode for plugin --- README.md | 4 ++++ openclaw.plugin.json | 7 ++++++ src/agent-control-plugin.ts | 48 +++++++++++++++++++++---------------- src/logging.ts | 17 +++++++++++++ src/tool-catalog.ts | 36 +++++++++++++++++----------- src/types.ts | 8 +++++++ 6 files changed, 86 insertions(+), 34 deletions(-) create mode 100644 src/logging.ts diff --git a/README.md b/README.md index 5edbed5..06244f0 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,14 @@ openclaw config set plugins.entries.agent-control-openclaw-plugin.config.timeout openclaw config set plugins.entries.agent-control-openclaw-plugin.config.failClosed false --strict-json # Optional settings +openclaw config set plugins.entries.agent-control-openclaw-plugin.config.debug true --strict-json openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentId "00000000-0000-4000-8000-000000000000" openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentVersion "2026.3.3" openclaw config set plugins.entries.agent-control-openclaw-plugin.config.userAgent "agent-control-plugin/0.1" # Remove optional keys openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.apiKey +openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.debug openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.agentId openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.agentVersion openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.userAgent @@ -79,3 +81,5 @@ openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.userA # Uninstall plugin link/install record from OpenClaw config openclaw plugins uninstall agent-control-openclaw-plugin --force ``` + +By default the plugin stays quiet and only emits warnings, errors, and tool block events. Set `config.debug` to `true` when you want verbose startup, sync, and evaluation diagnostics. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 72adbf5..fce7257 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -33,6 +33,9 @@ }, "failClosed": { "type": "boolean" + }, + "debug": { + "type": "boolean" } } }, @@ -56,6 +59,10 @@ "failClosed": { "label": "Fail Closed", "help": "If true, block tool invocations when Agent Control is unavailable." + }, + "debug": { + "label": "Debug Logging", + "help": "If true, emit verbose plugin diagnostics. If false, only warnings, errors, and block events are logged." } } } diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index 9894c7a..356fdb0 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -1,5 +1,6 @@ import { AgentControlClient } from "agent-control"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createPluginLogger } from "./logging.ts"; import { resolveStepsForContext } from "./tool-catalog.ts"; import { buildEvaluationContext } from "./session-context.ts"; import { @@ -63,10 +64,11 @@ export default function register(api: OpenClawPluginApi) { if (cfg.enabled === false) { return; } + const logger = createPluginLogger(api.logger, cfg.debug === true); const serverUrl = asString(cfg.serverUrl) ?? asString(process.env.AGENT_CONTROL_SERVER_URL); if (!serverUrl) { - api.logger.warn( + logger.warn( "agent-control: disabled because serverUrl is not configured (plugins.entries.agent-control-openclaw-plugin.config.serverUrl)", ); return; @@ -74,7 +76,7 @@ export default function register(api: OpenClawPluginApi) { const configuredAgentId = asString(cfg.agentId); if (configuredAgentId && !isUuid(configuredAgentId)) { - api.logger.warn(`agent-control: configured agentId is not a UUID: ${configuredAgentId}`); + logger.warn(`agent-control: configured agentId is not a UUID: ${configuredAgentId}`); } const hasConfiguredAgentId = configuredAgentId ? isUuid(configuredAgentId) : false; @@ -93,7 +95,7 @@ export default function register(api: OpenClawPluginApi) { timeoutMs: clientTimeoutMs, userAgent: asString(cfg.userAgent) ?? "openclaw-agent-control-plugin/0.1", }); - api.logger.info( + logger.debug( `agent-control: client_init duration_sec=${secondsSince(clientInitStartedAt)} timeout_ms=${clientTimeoutMs ?? "default"} server_url=${serverUrl}`, ); @@ -130,23 +132,24 @@ export default function register(api: OpenClawPluginApi) { const warmupStartedAt = process.hrtime.bigint(); gatewayWarmupStatus = "running"; - api.logger.info(`agent-control: gateway_boot_warmup started agent=${BOOT_WARMUP_AGENT_ID}`); + logger.debug(`agent-control: gateway_boot_warmup started agent=${BOOT_WARMUP_AGENT_ID}`); // Warm the exact resolver path used during tool evaluation so the gateway // process retains the expensive module graph in memory after startup. gatewayWarmupPromise = resolveStepsForContext({ api, + logger, sourceAgentId: BOOT_WARMUP_AGENT_ID, }) .then((steps) => { gatewayWarmupStatus = "done"; - api.logger.info( + logger.debug( `agent-control: gateway_boot_warmup done duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} steps=${steps.length}`, ); }) .catch((err) => { gatewayWarmupStatus = "failed"; - api.logger.warn( + logger.warn( `agent-control: gateway_boot_warmup failed duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} error=${String(err)}`, ); }); @@ -201,18 +204,18 @@ export default function register(api: OpenClawPluginApi) { const sourceAgentId = resolveSourceAgentId(ctx.agentId); const state = getOrCreateState(sourceAgentId); const argsForLog = formatToolArgsForLog(event.params); - api.logger.info( + logger.debug( `agent-control: before_tool_call entered agent=${sourceAgentId} tool=${event.toolName} args=${argsForLog}`, ); try { if (gatewayWarmupStatus === "running" && gatewayWarmupPromise) { const warmupWaitStartedAt = process.hrtime.bigint(); - api.logger.info( + logger.debug( `agent-control: before_tool_call waiting_for_gateway_boot_warmup=true agent=${sourceAgentId} tool=${event.toolName}`, ); await gatewayWarmupPromise; - api.logger.info( + logger.debug( `agent-control: before_tool_call phase=wait_boot_warmup duration_sec=${secondsSince(warmupWaitStartedAt)} agent=${sourceAgentId} tool=${event.toolName} warmup_status=${gatewayWarmupStatus}`, ); } @@ -221,6 +224,7 @@ export default function register(api: OpenClawPluginApi) { const resolveStepsStartedAt = process.hrtime.bigint(); const nextSteps = await resolveStepsForContext({ api, + logger, sourceAgentId, sessionKey: ctx.sessionKey, sessionId: ctx.sessionId, @@ -231,20 +235,23 @@ export default function register(api: OpenClawPluginApi) { state.steps = nextSteps; state.stepsHash = nextStepsHash; } - api.logger.info( + logger.debug( `agent-control: before_tool_call phase=resolve_steps duration_sec=${secondsSince(resolveStepsStartedAt)} agent=${sourceAgentId} tool=${event.toolName} steps=${nextSteps.length}`, ); const syncStartedAt = process.hrtime.bigint(); await syncAgent(state); - api.logger.info( + logger.debug( `agent-control: before_tool_call phase=sync_agent duration_sec=${secondsSince(syncStartedAt)} agent=${sourceAgentId} tool=${event.toolName} step_count=${state.steps.length}`, ); } catch (err) { - api.logger.warn( + logger.warn( `agent-control: unable to sync agent=${sourceAgentId} before tool evaluation: ${String(err)}`, ); if (failClosed) { + logger.block( + `agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=agent_sync_failed fail_closed=true`, + ); return { block: true, blockReason: USER_BLOCK_MESSAGE, @@ -274,11 +281,11 @@ export default function register(api: OpenClawPluginApi) { configuredAgentId, configuredAgentVersion, }); - api.logger.info( + logger.debug( `agent-control: before_tool_call phase=build_context duration_sec=${secondsSince(contextBuildStartedAt)} agent=${sourceAgentId} tool=${event.toolName}`, ); - api.logger.info( + logger.debug( `agent-control: before_tool_call evaluated agent=${sourceAgentId} tool=${event.toolName} args=${argsForLog} context=${JSON.stringify(context, null, 2)}`, ); @@ -295,17 +302,15 @@ export default function register(api: OpenClawPluginApi) { }, }, }); - api.logger.info( + logger.debug( `agent-control: before_tool_call phase=evaluate duration_sec=${secondsSince(evaluateStartedAt)} agent=${sourceAgentId} tool=${event.toolName} safe=${evaluation.isSafe}`, ); if (evaluation.isSafe) { - api.logger.info("safe !"); return; } - api.logger.info("unsafe !"); - api.logger.warn( + logger.block( `agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=${buildBlockReason(evaluation)}`, ); return { @@ -313,10 +318,13 @@ export default function register(api: OpenClawPluginApi) { blockReason: USER_BLOCK_MESSAGE, }; } catch (err) { - api.logger.warn( + logger.warn( `agent-control: evaluation failed for agent=${sourceAgentId} tool=${event.toolName}: ${String(err)}`, ); if (failClosed) { + logger.block( + `agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=evaluation_failed fail_closed=true`, + ); return { block: true, blockReason: USER_BLOCK_MESSAGE, @@ -325,7 +333,7 @@ export default function register(api: OpenClawPluginApi) { return; } } finally { - api.logger.info( + logger.debug( `agent-control: before_tool_call duration_sec=${secondsSince(beforeToolCallStartedAt)} agent=${sourceAgentId} tool=${event.toolName}`, ); } diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..b275fea --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,17 @@ +import type { LoggerLike, PluginLogger } from "./types.ts"; + +export function createPluginLogger(logger: LoggerLike, debugEnabled: boolean): PluginLogger { + return { + debug(message: string) { + if (debugEnabled) { + logger.info(message); + } + }, + warn(message: string) { + logger.warn(message); + }, + block(message: string) { + logger.warn(message); + }, + }; +} diff --git a/src/tool-catalog.ts b/src/tool-catalog.ts index 66f7fe3..c961774 100644 --- a/src/tool-catalog.ts +++ b/src/tool-catalog.ts @@ -2,7 +2,13 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { AgentControlStep, LoggerLike, ResolveStepsForContextParams, ToolCatalogBundleBuildInfo, ToolCatalogInternals } from "./types.ts"; +import type { + AgentControlStep, + PluginLogger, + ResolveStepsForContextParams, + ToolCatalogBundleBuildInfo, + ToolCatalogInternals, +} from "./types.ts"; import { asString, sanitizeToolCatalogConfig, secondsSince, toJsonRecord } from "./shared.ts"; import { getResolvedOpenClawRootDir, @@ -51,14 +57,14 @@ function hasToolCatalogBundleSources(openClawRoot: string): boolean { } async function importToolCatalogBundleModule( - logger: LoggerLike, + logger: PluginLogger, buildInfo: ToolCatalogBundleBuildInfo, ): Promise> { const importStartedAt = process.hrtime.bigint(); const bundleMtime = safeStatMtimeMs(buildInfo.bundlePath) ?? Date.now(); const bundleUrl = `${pathToFileURL(buildInfo.bundlePath).href}?mtime=${bundleMtime}`; const imported = (await import(bundleUrl)) as Record; - logger.info( + logger.debug( `agent-control: bundle_import_done duration_sec=${secondsSince(importStartedAt)} cache_key=${buildInfo.cacheKey} bundle_path=${buildInfo.bundlePath}`, ); return imported; @@ -85,18 +91,18 @@ function resolveToolCatalogInternalsFromModules(params: { } async function ensureToolCatalogBundle( - logger: LoggerLike, + logger: PluginLogger, buildInfo: ToolCatalogBundleBuildInfo, ): Promise { if (fs.existsSync(buildInfo.bundlePath)) { - logger.info( + logger.debug( `agent-control: bundle_cache_hit cache_key=${buildInfo.cacheKey} bundle_path=${buildInfo.bundlePath}`, ); return; } const esbuildStartedAt = process.hrtime.bigint(); - logger.info( + logger.debug( `agent-control: bundle_build_started cache_key=${buildInfo.cacheKey} openclaw_root=${buildInfo.openClawRoot}`, ); @@ -155,13 +161,13 @@ async function ensureToolCatalogBundle( )}\n`, "utf8", ); - logger.info( + logger.debug( `agent-control: bundle_build_done duration_sec=${secondsSince(esbuildStartedAt)} cache_key=${buildInfo.cacheKey} bundle_path=${buildInfo.bundlePath}`, ); } async function loadToolCatalogInternalsFromGeneratedBundle( - logger: LoggerLike, + logger: PluginLogger, openClawRoot: string, ): Promise { if (!hasToolCatalogBundleSources(openClawRoot)) { @@ -194,7 +200,7 @@ async function loadToolCatalogInternalsFromGeneratedBundle( } } -async function loadToolCatalogInternals(logger: LoggerLike): Promise { +async function loadToolCatalogInternals(logger: PluginLogger): Promise { if (toolCatalogInternalsPromise) { return toolCatalogInternalsPromise; } @@ -212,7 +218,9 @@ async function loadToolCatalogInternals(logger: LoggerLike): Promise { const resolveStartedAt = process.hrtime.bigint(); const internalsStartedAt = process.hrtime.bigint(); - const internals = await loadToolCatalogInternals(params.api.logger); + const internals = await loadToolCatalogInternals(params.logger); const internalsDurationSec = secondsSince(internalsStartedAt); const createToolsStartedAt = process.hrtime.bigint(); @@ -318,7 +326,7 @@ export async function resolveStepsForContext( ); const adaptDurationSec = secondsSince(adaptStartedAt); - params.api.logger.info( + params.logger.debug( `agent-control: resolve_steps duration_sec=${secondsSince(resolveStartedAt)} agent=${params.sourceAgentId} internals_sec=${internalsDurationSec} create_tools_sec=${createToolsDurationSec} adapt_sec=${adaptDurationSec} tools=${tools.length} steps=${steps.length}`, ); diff --git a/src/types.ts b/src/types.ts index f81d150..00779af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export type AgentControlPluginConfig = { timeoutMs?: number; userAgent?: string; failClosed?: boolean; + debug?: boolean; }; export type AgentControlStep = { @@ -80,6 +81,12 @@ export type SessionMetadataCacheEntry = { export type LoggerLike = Pick; +export type PluginLogger = { + debug: (message: string) => void; + warn: (message: string) => void; + block: (message: string) => void; +}; + export type ToolCatalogBundleBuildInfo = { bundlePath: string; cacheDir: string; @@ -91,6 +98,7 @@ export type ToolCatalogBundleBuildInfo = { export type ResolveStepsForContextParams = { api: OpenClawPluginApi; + logger: PluginLogger; sourceAgentId: string; sessionKey?: string; sessionId?: string; From a47097d0c9da3194b1b0c44bbc03af260e0bfca4 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 11:41:02 -0700 Subject: [PATCH 2/9] Add log level support for plugin logging --- README.md | 13 +++++++++++-- openclaw.plugin.json | 10 +++++++++- src/agent-control-plugin.ts | 14 +++++++++----- src/logging.ts | 29 +++++++++++++++++++++++++++-- src/types.ts | 4 ++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 06244f0..3b03a13 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,15 @@ openclaw config set plugins.entries.agent-control-openclaw-plugin.config.timeout openclaw config set plugins.entries.agent-control-openclaw-plugin.config.failClosed false --strict-json # Optional settings -openclaw config set plugins.entries.agent-control-openclaw-plugin.config.debug true --strict-json +openclaw config set plugins.entries.agent-control-openclaw-plugin.config.logLevel "info" +openclaw config set plugins.entries.agent-control-openclaw-plugin.config.logLevel "debug" openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentId "00000000-0000-4000-8000-000000000000" openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentVersion "2026.3.3" openclaw config set plugins.entries.agent-control-openclaw-plugin.config.userAgent "agent-control-plugin/0.1" # Remove optional keys openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.apiKey +openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.logLevel openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.debug openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.agentId openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.agentVersion @@ -82,4 +84,11 @@ openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.userA openclaw plugins uninstall agent-control-openclaw-plugin --force ``` -By default the plugin stays quiet and only emits warnings, errors, and tool block events. Set `config.debug` to `true` when you want verbose startup, sync, and evaluation diagnostics. +By default the plugin stays quiet and only emits warnings, errors, and tool block events. + +Set `config.logLevel` to: + +- `info` for one-line lifecycle logs such as client init, warmup, and agent syncs +- `debug` for verbose startup, sync, and evaluation diagnostics + +The older `config.debug` flag is still accepted as a deprecated alias for `logLevel=debug`. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index fce7257..ecadb48 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -34,6 +34,10 @@ "failClosed": { "type": "boolean" }, + "logLevel": { + "type": "string", + "enum": ["warn", "info", "debug"] + }, "debug": { "type": "boolean" } @@ -60,9 +64,13 @@ "label": "Fail Closed", "help": "If true, block tool invocations when Agent Control is unavailable." }, + "logLevel": { + "label": "Log Level", + "help": "Controls plugin verbosity: warn logs only warnings, errors, and block events; info adds high-level lifecycle logs; debug adds verbose diagnostics." + }, "debug": { "label": "Debug Logging", - "help": "If true, emit verbose plugin diagnostics. If false, only warnings, errors, and block events are logged." + "help": "Deprecated alias for logLevel=debug." } } } diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index 356fdb0..38cb2be 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -1,6 +1,6 @@ import { AgentControlClient } from "agent-control"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createPluginLogger } from "./logging.ts"; +import { createPluginLogger, resolveLogLevel } from "./logging.ts"; import { resolveStepsForContext } from "./tool-catalog.ts"; import { buildEvaluationContext } from "./session-context.ts"; import { @@ -64,7 +64,7 @@ export default function register(api: OpenClawPluginApi) { if (cfg.enabled === false) { return; } - const logger = createPluginLogger(api.logger, cfg.debug === true); + const logger = createPluginLogger(api.logger, resolveLogLevel(cfg)); const serverUrl = asString(cfg.serverUrl) ?? asString(process.env.AGENT_CONTROL_SERVER_URL); if (!serverUrl) { @@ -95,7 +95,7 @@ export default function register(api: OpenClawPluginApi) { timeoutMs: clientTimeoutMs, userAgent: asString(cfg.userAgent) ?? "openclaw-agent-control-plugin/0.1", }); - logger.debug( + logger.info( `agent-control: client_init duration_sec=${secondsSince(clientInitStartedAt)} timeout_ms=${clientTimeoutMs ?? "default"} server_url=${serverUrl}`, ); @@ -132,7 +132,7 @@ export default function register(api: OpenClawPluginApi) { const warmupStartedAt = process.hrtime.bigint(); gatewayWarmupStatus = "running"; - logger.debug(`agent-control: gateway_boot_warmup started agent=${BOOT_WARMUP_AGENT_ID}`); + logger.info(`agent-control: gateway_boot_warmup started agent=${BOOT_WARMUP_AGENT_ID}`); // Warm the exact resolver path used during tool evaluation so the gateway // process retains the expensive module graph in memory after startup. @@ -143,7 +143,7 @@ export default function register(api: OpenClawPluginApi) { }) .then((steps) => { gatewayWarmupStatus = "done"; - logger.debug( + logger.info( `agent-control: gateway_boot_warmup done duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} steps=${steps.length}`, ); }) @@ -168,6 +168,7 @@ export default function register(api: OpenClawPluginApi) { const currentHash = state.stepsHash; const promise = (async () => { + const syncStartedAt = process.hrtime.bigint(); await client.agents.init({ agent: { agentName: state.agentName, @@ -181,6 +182,9 @@ export default function register(api: OpenClawPluginApi) { }, steps: state.steps, }); + logger.info( + `agent-control: sync_agent duration_sec=${secondsSince(syncStartedAt)} agent=${state.sourceAgentId} step_count=${state.steps.length}`, + ); state.lastSyncedStepsHash = currentHash; })().finally(() => { state.syncPromise = null; diff --git a/src/logging.ts b/src/logging.ts index b275fea..8e9ed8d 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,7 +1,32 @@ -import type { LoggerLike, PluginLogger } from "./types.ts"; +import { asString } from "./shared.ts"; +import type { AgentControlPluginConfig, LogLevel, LoggerLike, PluginLogger } from "./types.ts"; -export function createPluginLogger(logger: LoggerLike, debugEnabled: boolean): PluginLogger { +const LOG_LEVELS: LogLevel[] = ["warn", "info", "debug"]; + +function isLogLevel(value: string): value is LogLevel { + return LOG_LEVELS.includes(value as LogLevel); +} + +export function resolveLogLevel(cfg: AgentControlPluginConfig): LogLevel { + const configuredLevel = asString(cfg.logLevel)?.toLowerCase(); + if (configuredLevel && isLogLevel(configuredLevel)) { + return configuredLevel; + } + if (cfg.debug === true) { + return "debug"; + } + return "warn"; +} + +export function createPluginLogger(logger: LoggerLike, logLevel: LogLevel): PluginLogger { + const infoEnabled = logLevel === "info" || logLevel === "debug"; + const debugEnabled = logLevel === "debug"; return { + info(message: string) { + if (infoEnabled) { + logger.info(message); + } + }, debug(message: string) { if (debugEnabled) { logger.info(message); diff --git a/src/types.ts b/src/types.ts index 00779af..dd6764a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +export type LogLevel = "warn" | "info" | "debug"; + export type AgentControlPluginConfig = { enabled?: boolean; serverUrl?: string; @@ -10,6 +12,7 @@ export type AgentControlPluginConfig = { timeoutMs?: number; userAgent?: string; failClosed?: boolean; + logLevel?: LogLevel; debug?: boolean; }; @@ -82,6 +85,7 @@ export type SessionMetadataCacheEntry = { export type LoggerLike = Pick; export type PluginLogger = { + info: (message: string) => void; debug: (message: string) => void; warn: (message: string) => void; block: (message: string) => void; From 1e8ea693a0bce447d5614c1ab49a18ba7ed7c9fc Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 11:46:47 -0700 Subject: [PATCH 3/9] Add tests and CI checks --- .github/workflows/lint.yml | 11 +- README.md | 3 + package-lock.json | 33 +++++ package.json | 4 + src/agent-control-plugin.ts | 8 +- src/session-store.ts | 12 +- test/agent-control-plugin.test.ts | 193 ++++++++++++++++++++++++++++ test/logging.test.ts | 61 +++++++++ tsconfig.json | 14 ++ types/openclaw-plugin-sdk-core.d.ts | 36 ++++++ 10 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 test/agent-control-plugin.test.ts create mode 100644 test/logging.test.ts create mode 100644 tsconfig.json create mode 100644 types/openclaw-plugin-sdk-core.d.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f319c22..6d2b829 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Lint +name: CI on: push: @@ -15,7 +15,7 @@ concurrency: cancel-in-progress: true jobs: - lint: + checks: runs-on: ubuntu-latest steps: - name: Check out repository @@ -25,9 +25,16 @@ jobs: uses: actions/setup-node@v4 with: node-version: "24" + cache: npm - name: Install dependencies run: npm ci - name: Run lint run: npm run lint + + - name: Run typecheck + run: npm run typecheck + + - name: Run tests + run: npm test diff --git a/README.md b/README.md index 3b03a13..a205ae7 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Then restart the gateway. ```bash npm install +npm run lint +npm run typecheck +npm test ``` 3. Link it into your OpenClaw config from your OpenClaw checkout: diff --git a/package-lock.json b/package-lock.json index d9ba273..9579805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,10 @@ }, "devDependencies": { "@semantic-release/git": "^10.0.1", + "@types/node": "^24.5.2", "oxlint": "^0.15.0", "semantic-release": "^25.0.3", + "typescript": "^5.9.2", "vitest": "^4.0.18" } }, @@ -1665,6 +1667,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -7002,6 +7014,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -7026,6 +7052,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", diff --git a/package.json b/package.json index e94d10d..7caf515 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "scripts": { "lint": "oxlint .", "lint:fix": "oxlint . --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", "release": "semantic-release" }, "dependencies": { @@ -38,8 +40,10 @@ }, "devDependencies": { "@semantic-release/git": "^10.0.1", + "@types/node": "^24.5.2", "oxlint": "^0.15.0", "semantic-release": "^25.0.3", + "typescript": "^5.9.2", "vitest": "^4.0.18" }, "openclaw": { diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index 38cb2be..802f989 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -18,8 +18,8 @@ import { import type { AgentControlPluginConfig, AgentState } from "./types.ts"; function collectDenyControlNames(response: { - matches?: Array<{ action?: string; controlName?: string }>; - errors?: Array<{ action?: string; controlName?: string }>; + matches?: Array<{ action?: string; controlName?: string }> | null; + errors?: Array<{ action?: string; controlName?: string }> | null; }): string[] { const names: string[] = []; for (const match of [...(response.matches ?? []), ...(response.errors ?? [])]) { @@ -36,8 +36,8 @@ function collectDenyControlNames(response: { function buildBlockReason(response: { reason?: string | null; - matches?: Array<{ action?: string; controlName?: string }>; - errors?: Array<{ action?: string; controlName?: string }>; + matches?: Array<{ action?: string; controlName?: string }> | null; + errors?: Array<{ action?: string; controlName?: string }> | null; }): string { const denyControls = collectDenyControlNames(response); if (denyControls.length > 0) { diff --git a/src/session-store.ts b/src/session-store.ts index 46b9259..0ba3023 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -148,11 +148,13 @@ export async function resolveSessionIdentity( const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined; const storePath = internals.resolveStorePath(asString(sessionCfg?.store)); const store = internals.loadSessionStore(storePath); - const entry = - (isRecord(store[normalizedKey]) ? store[normalizedKey] : undefined) ?? - (isRecord(store[resolveBaseSessionKey(normalizedKey)]) - ? store[resolveBaseSessionKey(normalizedKey)] - : undefined); + const directEntry = store[normalizedKey]; + const baseEntry = store[resolveBaseSessionKey(normalizedKey)]; + const entry: Record | undefined = isRecord(directEntry) + ? directEntry + : isRecord(baseEntry) + ? baseEntry + : undefined; const data = entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity(); setSessionMetadataCache(normalizedKey, data); return data; diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts new file mode 100644 index 0000000..0150336 --- /dev/null +++ b/test/agent-control-plugin.test.ts @@ -0,0 +1,193 @@ +import type { + OpenClawBeforeToolCallContext, + OpenClawBeforeToolCallEvent, + OpenClawPluginApi, +} from "openclaw/plugin-sdk/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { USER_BLOCK_MESSAGE } from "../src/shared.ts"; + +const { + clientMocks, + resolveStepsForContextMock, + buildEvaluationContextMock, +} = vi.hoisted(() => ({ + clientMocks: { + init: vi.fn(), + agentsInit: vi.fn(), + evaluationEvaluate: vi.fn(), + }, + resolveStepsForContextMock: vi.fn(), + buildEvaluationContextMock: vi.fn(), +})); + +vi.mock("agent-control", () => ({ + AgentControlClient: class MockAgentControlClient { + init = clientMocks.init; + agents = { + init: clientMocks.agentsInit, + }; + evaluation = { + evaluate: clientMocks.evaluationEvaluate, + }; + }, +})); + +vi.mock("../src/tool-catalog.ts", () => ({ + resolveStepsForContext: resolveStepsForContextMock, +})); + +vi.mock("../src/session-context.ts", () => ({ + buildEvaluationContext: buildEvaluationContextMock, +})); + +import register from "../src/agent-control-plugin.ts"; + +type MockApi = { + api: OpenClawPluginApi; + handlers: Map unknown>; + info: ReturnType; + warn: ReturnType; +}; + +function createMockApi(pluginConfig: Record): MockApi { + const handlers = new Map unknown>(); + const info = vi.fn(); + const warn = vi.fn(); + + const api: OpenClawPluginApi = { + id: "agent-control-openclaw-plugin", + version: "test-version", + config: {}, + pluginConfig, + logger: { + info, + warn, + }, + on(event, handler) { + handlers.set(event, handler as (...args: any[]) => unknown); + }, + }; + + return { api, handlers, info, warn }; +} + +async function runBeforeToolCall( + api: MockApi, + event: Partial = {}, + ctx: Partial = {}, +): Promise { + const handler = api.handlers.get("before_tool_call"); + if (!handler) { + throw new Error("before_tool_call handler was not registered"); + } + return handler( + { + toolName: "shell", + params: { cmd: "echo hi" }, + runId: "run-1", + toolCallId: "call-1", + ...event, + }, + ctx, + ); +} + +async function runGatewayStart(api: MockApi): Promise { + const handler = api.handlers.get("gateway_start"); + if (!handler) { + throw new Error("gateway_start handler was not registered"); + } + await handler(); +} + +beforeEach(() => { + clientMocks.init.mockReset(); + clientMocks.agentsInit.mockReset().mockResolvedValue(undefined); + clientMocks.evaluationEvaluate.mockReset().mockResolvedValue({ isSafe: true }); + resolveStepsForContextMock.mockReset().mockResolvedValue([{ type: "tool", name: "shell" }]); + buildEvaluationContextMock.mockReset().mockResolvedValue({ channelType: "unknown" }); +}); + +describe("agent-control plugin logging and blocking", () => { + it("keeps warn mode quiet except for block events", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + reason: "denied by policy", + }); + + register(api.api); + const result = await runBeforeToolCall(api); + + expect(result).toEqual({ + block: true, + blockReason: USER_BLOCK_MESSAGE, + }); + expect(api.info).not.toHaveBeenCalled(); + expect(api.warn).toHaveBeenCalledTimes(1); + expect(api.warn.mock.calls[0]?.[0]).toContain("blocked tool=shell"); + expect(clientMocks.agentsInit).toHaveBeenCalledOnce(); + expect(clientMocks.evaluationEvaluate).toHaveBeenCalledOnce(); + }); + + it("adds lifecycle logs in info mode without debug traces", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + logLevel: "info", + }); + + register(api.api); + await runGatewayStart(api); + await runBeforeToolCall(api); + + const messages = api.info.mock.calls.map(([message]) => String(message)); + expect(messages.some((message) => message.includes("client_init"))).toBe(true); + expect(messages.some((message) => message.includes("gateway_boot_warmup started"))).toBe(true); + expect(messages.some((message) => message.includes("gateway_boot_warmup done"))).toBe(true); + expect(messages.some((message) => message.includes("sync_agent"))).toBe(true); + expect(messages.some((message) => message.includes("before_tool_call entered"))).toBe(false); + expect(messages.some((message) => message.includes("evaluated agent="))).toBe(false); + }); + + it("accepts the deprecated debug flag as an alias for debug logging", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + debug: true, + }); + + register(api.api); + await runBeforeToolCall(api); + + const messages = api.info.mock.calls.map(([message]) => String(message)); + expect(messages.some((message) => message.includes("before_tool_call entered"))).toBe(true); + expect(messages.some((message) => message.includes("phase=evaluate"))).toBe(true); + }); + + it("blocks in fail-closed mode when step resolution fails", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + failClosed: true, + }); + + resolveStepsForContextMock.mockRejectedValueOnce(new Error("resolver exploded")); + + register(api.api); + const result = await runBeforeToolCall(api); + + expect(result).toEqual({ + block: true, + blockReason: USER_BLOCK_MESSAGE, + }); + expect(buildEvaluationContextMock).not.toHaveBeenCalled(); + expect(clientMocks.evaluationEvaluate).not.toHaveBeenCalled(); + expect(api.warn.mock.calls.map(([message]) => String(message))).toEqual( + expect.arrayContaining([ + expect.stringContaining("unable to sync"), + expect.stringContaining("blocked tool=shell agent=default reason=agent_sync_failed fail_closed=true"), + ]), + ); + }); +}); diff --git a/test/logging.test.ts b/test/logging.test.ts new file mode 100644 index 0000000..8bfa580 --- /dev/null +++ b/test/logging.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPluginLogger, resolveLogLevel } from "../src/logging.ts"; + +describe("resolveLogLevel", () => { + it("defaults to warn", () => { + expect(resolveLogLevel({})).toBe("warn"); + }); + + it("uses an explicit logLevel when provided", () => { + expect(resolveLogLevel({ logLevel: "info" })).toBe("info"); + expect(resolveLogLevel({ logLevel: "debug" })).toBe("debug"); + }); + + it("lets logLevel override the deprecated debug flag", () => { + expect(resolveLogLevel({ logLevel: "warn", debug: true })).toBe("warn"); + }); + + it("falls back to the deprecated debug flag when logLevel is invalid", () => { + expect(resolveLogLevel({ logLevel: "verbose" as never, debug: true })).toBe("debug"); + }); +}); + +describe("createPluginLogger", () => { + it("only emits warnings in warn mode", () => { + const info = vi.fn(); + const warn = vi.fn(); + const logger = createPluginLogger({ info, warn }, "warn"); + + logger.info("info"); + logger.debug("debug"); + logger.warn("warn"); + logger.block("block"); + + expect(info).not.toHaveBeenCalled(); + expect(warn.mock.calls).toEqual([["warn"], ["block"]]); + }); + + it("emits info logs in info mode but still suppresses debug traces", () => { + const info = vi.fn(); + const warn = vi.fn(); + const logger = createPluginLogger({ info, warn }, "info"); + + logger.info("info"); + logger.debug("debug"); + + expect(info.mock.calls).toEqual([["info"]]); + expect(warn).not.toHaveBeenCalled(); + }); + + it("emits both info and debug logs in debug mode", () => { + const info = vi.fn(); + const warn = vi.fn(); + const logger = createPluginLogger({ info, warn }, "debug"); + + logger.info("info"); + logger.debug("debug"); + + expect(info.mock.calls).toEqual([["info"], ["debug"]]); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..491049d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + }, + "include": ["index.ts", "src/**/*.ts", "test/**/*.ts", "types/**/*.d.ts"] +} diff --git a/types/openclaw-plugin-sdk-core.d.ts b/types/openclaw-plugin-sdk-core.d.ts new file mode 100644 index 0000000..7fd01fc --- /dev/null +++ b/types/openclaw-plugin-sdk-core.d.ts @@ -0,0 +1,36 @@ +declare module "openclaw/plugin-sdk/core" { + export type OpenClawBeforeToolCallEvent = { + toolName: string; + params?: unknown; + runId?: string; + toolCallId?: string; + }; + + export type OpenClawBeforeToolCallContext = { + agentId?: string; + sessionKey?: string; + sessionId?: string; + runId?: string; + toolCallId?: string; + }; + + export interface OpenClawPluginApi { + id: string; + version?: string; + config: Record; + pluginConfig?: unknown; + logger: { + info(message: string): void; + warn(message: string): void; + }; + on(event: "gateway_start", handler: () => void | Promise): void; + on( + event: "before_tool_call", + handler: ( + event: OpenClawBeforeToolCallEvent, + ctx: OpenClawBeforeToolCallContext, + ) => unknown, + ): void; + on(event: string, handler: (...args: any[]) => unknown): void; + } +} From 4e5333c619d278a2f7c9b39608dba2bef443cb1a Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 11:54:53 -0700 Subject: [PATCH 4/9] Expand behavioral test coverage --- test/agent-control-plugin.test.ts | 192 +++++++++++++++++++++++++- test/logging.test.ts | 14 +- test/openclaw-runtime.test.ts | 121 +++++++++++++++++ test/session-context.test.ts | 174 ++++++++++++++++++++++++ test/session-store.test.ts | 206 ++++++++++++++++++++++++++++ test/shared.test.ts | 54 ++++++++ test/tool-catalog.test.ts | 217 ++++++++++++++++++++++++++++++ 7 files changed, 967 insertions(+), 11 deletions(-) create mode 100644 test/openclaw-runtime.test.ts create mode 100644 test/session-context.test.ts create mode 100644 test/session-store.test.ts create mode 100644 test/shared.test.ts create mode 100644 test/tool-catalog.test.ts diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index 0150336..f5574f1 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -42,6 +42,8 @@ vi.mock("../src/session-context.ts", () => ({ import register from "../src/agent-control-plugin.ts"; +const VALID_AGENT_ID = "00000000-0000-4000-8000-000000000000"; + type MockApi = { api: OpenClawPluginApi; handlers: Map unknown>; @@ -49,6 +51,16 @@ type MockApi = { warn: ReturnType; }; +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function createMockApi(pluginConfig: Record): MockApi { const handlers = new Map unknown>(); const info = vi.fn(); @@ -109,7 +121,44 @@ beforeEach(() => { }); describe("agent-control plugin logging and blocking", () => { - it("keeps warn mode quiet except for block events", async () => { + it("Given the plugin is disabled, when it is registered, then it does not initialize the client or hooks", () => { + const api = createMockApi({ + enabled: false, + serverUrl: "http://localhost:8000", + }); + + register(api.api); + + expect(clientMocks.init).not.toHaveBeenCalled(); + expect(api.handlers.size).toBe(0); + }); + + it("Given no server URL is configured, when the plugin is registered, then it warns and skips hook registration", () => { + const api = createMockApi({}); + + register(api.api); + + expect(clientMocks.init).not.toHaveBeenCalled(); + expect(api.handlers.size).toBe(0); + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining("disabled because serverUrl is not configured"), + ); + }); + + it("Given an invalid configured agent ID, when the plugin is registered, then it warns about the invalid UUID", () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + agentId: "not-a-uuid", + }); + + register(api.api); + + expect(api.warn).toHaveBeenCalledWith( + "agent-control: configured agentId is not a UUID: not-a-uuid", + ); + }); + + it("Given warn mode, when an unsafe evaluation occurs, then only the block event is logged", async () => { const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -133,7 +182,7 @@ describe("agent-control plugin logging and blocking", () => { expect(clientMocks.evaluationEvaluate).toHaveBeenCalledOnce(); }); - it("adds lifecycle logs in info mode without debug traces", async () => { + it("Given info mode, when warmup and a tool evaluation run, then lifecycle logs are emitted without debug traces", async () => { const api = createMockApi({ serverUrl: "http://localhost:8000", logLevel: "info", @@ -152,7 +201,7 @@ describe("agent-control plugin logging and blocking", () => { expect(messages.some((message) => message.includes("evaluated agent="))).toBe(false); }); - it("accepts the deprecated debug flag as an alias for debug logging", async () => { + it("Given the deprecated debug flag, when a tool evaluation runs, then verbose debug traces are emitted", async () => { const api = createMockApi({ serverUrl: "http://localhost:8000", debug: true, @@ -166,7 +215,7 @@ describe("agent-control plugin logging and blocking", () => { expect(messages.some((message) => message.includes("phase=evaluate"))).toBe(true); }); - it("blocks in fail-closed mode when step resolution fails", async () => { + it("Given fail-closed mode, when step resolution fails, then the tool call is blocked before evaluation", async () => { const api = createMockApi({ serverUrl: "http://localhost:8000", failClosed: true, @@ -190,4 +239,139 @@ describe("agent-control plugin logging and blocking", () => { ]), ); }); + + it("Given a fixed configured agent ID, when a source agent evaluates a tool, then the base agent name is used without a source suffix", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + agentId: VALID_AGENT_ID, + agentName: "base-agent", + }); + + register(api.api); + await runBeforeToolCall(api, {}, { agentId: "worker-1" }); + + expect(clientMocks.agentsInit).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ + agentName: "base-agent", + agentMetadata: expect.objectContaining({ + openclawConfiguredAgentId: VALID_AGENT_ID, + }), + }), + }), + ); + }); + + it("Given no configured agent ID, when a source agent evaluates a tool, then the source agent ID is appended to the base agent name", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + agentName: "base-agent", + }); + + register(api.api); + await runBeforeToolCall(api, {}, { agentId: "worker-1" }); + + expect(clientMocks.agentsInit).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ + agentName: "base-agent:worker-1", + }), + }), + ); + }); + + it("Given gateway warmup has already started, when gateway_start fires again, then the warmup work is reused", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + register(api.api); + await runGatewayStart(api); + await runGatewayStart(api); + + expect(resolveStepsForContextMock).toHaveBeenCalledTimes(1); + expect(resolveStepsForContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAgentId: "main", + }), + ); + }); + + it("Given two concurrent tool calls for the same source agent, when sync is already in flight, then Agent Control is initialized only once", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + const syncDeferred = createDeferred(); + clientMocks.agentsInit.mockImplementation(() => syncDeferred.promise); + + register(api.api); + + const first = runBeforeToolCall(api); + const second = runBeforeToolCall(api); + await Promise.resolve(); + await Promise.resolve(); + + expect(clientMocks.agentsInit).toHaveBeenCalledTimes(1); + + syncDeferred.resolve(undefined); + await Promise.all([first, second]); + + expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); + }); + + it("Given the synced step catalog is unchanged, when the same source agent evaluates another tool, then the second call skips resyncing the agent", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + register(api.api); + await runBeforeToolCall(api); + await runBeforeToolCall(api); + + expect(clientMocks.agentsInit).toHaveBeenCalledTimes(1); + expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); + }); + + it("Given duplicate deny controls in the evaluation response, when the tool call is blocked, then the block reason lists each control once", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + matches: [ + { action: "deny", controlName: "alpha" }, + { action: "deny", controlName: "alpha" }, + { action: "deny", controlName: "beta" }, + ], + errors: null, + }); + + register(api.api); + await runBeforeToolCall(api); + + const message = String(api.warn.mock.calls[0]?.[0]); + expect(message).toContain("alpha, beta"); + expect(message).not.toContain("alpha, alpha"); + }); + + it("Given no policy reason or deny controls are returned, when the tool call is blocked, then the generic block reason is logged", async () => { + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + reason: "", + matches: null, + errors: null, + }); + + register(api.api); + await runBeforeToolCall(api); + + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining("reason=[agent-control] blocked by policy evaluation"), + ); + }); }); diff --git a/test/logging.test.ts b/test/logging.test.ts index 8bfa580..d945336 100644 --- a/test/logging.test.ts +++ b/test/logging.test.ts @@ -2,26 +2,26 @@ import { describe, expect, it, vi } from "vitest"; import { createPluginLogger, resolveLogLevel } from "../src/logging.ts"; describe("resolveLogLevel", () => { - it("defaults to warn", () => { + it("Given no logging configuration, when the log level is resolved, then warn is used", () => { expect(resolveLogLevel({})).toBe("warn"); }); - it("uses an explicit logLevel when provided", () => { + it("Given an explicit log level, when the log level is resolved, then the configured level is used", () => { expect(resolveLogLevel({ logLevel: "info" })).toBe("info"); expect(resolveLogLevel({ logLevel: "debug" })).toBe("debug"); }); - it("lets logLevel override the deprecated debug flag", () => { + it("Given both logLevel and the deprecated debug flag, when the log level is resolved, then logLevel wins", () => { expect(resolveLogLevel({ logLevel: "warn", debug: true })).toBe("warn"); }); - it("falls back to the deprecated debug flag when logLevel is invalid", () => { + it("Given an invalid logLevel and debug=true, when the log level is resolved, then debug is used as a compatibility fallback", () => { expect(resolveLogLevel({ logLevel: "verbose" as never, debug: true })).toBe("debug"); }); }); describe("createPluginLogger", () => { - it("only emits warnings in warn mode", () => { + it("Given warn mode, when info and debug messages are emitted, then only warning-class messages are forwarded", () => { const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "warn"); @@ -35,7 +35,7 @@ describe("createPluginLogger", () => { expect(warn.mock.calls).toEqual([["warn"], ["block"]]); }); - it("emits info logs in info mode but still suppresses debug traces", () => { + it("Given info mode, when info and debug messages are emitted, then lifecycle info is forwarded and debug traces stay suppressed", () => { const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "info"); @@ -47,7 +47,7 @@ describe("createPluginLogger", () => { expect(warn).not.toHaveBeenCalled(); }); - it("emits both info and debug logs in debug mode", () => { + it("Given debug mode, when info and debug messages are emitted, then both are forwarded", () => { const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "debug"); diff --git a/test/openclaw-runtime.test.ts b/test/openclaw-runtime.test.ts new file mode 100644 index 0000000..8e4f4a9 --- /dev/null +++ b/test/openclaw-runtime.test.ts @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalCwd = process.cwd(); + +async function loadRuntimeModule(options: { packageResolve?: string | Error } = {}) { + vi.resetModules(); + vi.doMock("node:module", () => ({ + createRequire: () => ({ + resolve: () => { + if (options.packageResolve instanceof Error) { + throw options.packageResolve; + } + if (typeof options.packageResolve === "string") { + return options.packageResolve; + } + throw new Error("openclaw package.json was not found"); + }, + }), + })); + return import("../src/openclaw-runtime.ts"); +} + +afterEach(() => { + process.chdir(originalCwd); + vi.unmock("node:module"); + vi.unmock("../src/openclaw-runtime.ts"); +}); + +describe("openclaw runtime helpers", () => { + it("Given a valid package.json, when package fields are read, then name and version are returned", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-pkg-")); + const packageJsonPath = path.join(tempDir, "package.json"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ name: "openclaw", version: "1.2.3" }), + "utf8", + ); + + const runtime = await loadRuntimeModule(); + + expect(runtime.readPackageName(packageJsonPath)).toBe("openclaw"); + expect(runtime.readPackageVersion(packageJsonPath)).toBe("1.2.3"); + }); + + it("Given source and target files in sibling directories, when the import path is normalized, then a relative posix path with a leading dot is returned", async () => { + const runtime = await loadRuntimeModule(); + + expect(runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/c/tool.ts")).toBe("../c/tool.ts"); + expect(runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/b/tool.ts")).toBe("./tool.ts"); + }); + + it("Given package resolution is unavailable but the current working directory is inside an OpenClaw checkout, when the root dir is resolved, then the checkout root is found from cwd", async () => { + const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-root-")); + fs.writeFileSync( + path.join(openClawRoot, "package.json"), + JSON.stringify({ name: "openclaw" }), + "utf8", + ); + const nestedDir = path.join(openClawRoot, "src", "agents"); + fs.mkdirSync(nestedDir, { recursive: true }); + process.chdir(nestedDir); + + const runtime = await loadRuntimeModule({ + packageResolve: new Error("not found"), + }); + + expect(runtime.getResolvedOpenClawRootDir()).toBe(fs.realpathSync(openClawRoot)); + }); + + it("Given package resolution is unavailable and no OpenClaw checkout can be found, when the root dir is resolved, then a helpful error is thrown", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-")); + process.chdir(tempDir); + + const runtime = await loadRuntimeModule({ + packageResolve: new Error("not found"), + }); + + expect(() => runtime.getResolvedOpenClawRootDir()).toThrow( + "agent-control: unable to resolve openclaw package root for internal tool schema access", + ); + }); + + it("Given a JavaScript candidate exists, when tryImportOpenClawInternalModule is called, then the first importable candidate is returned", async () => { + const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-js-")); + fs.mkdirSync(path.join(openClawRoot, "dist"), { recursive: true }); + fs.writeFileSync(path.join(openClawRoot, "dist", "candidate.mjs"), "export const value = 123;\n", "utf8"); + + const runtime = await loadRuntimeModule(); + const imported = await runtime.tryImportOpenClawInternalModule(openClawRoot, [ + "dist/missing.mjs", + "dist/candidate.mjs", + ]); + + expect(imported).toMatchObject({ value: 123 }); + }); + + it("Given a TypeScript candidate exists, when importOpenClawInternalModule is called, then the module is loaded through jiti", async () => { + const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-ts-")); + fs.mkdirSync(path.join(openClawRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(openClawRoot, "src", "candidate.ts"), "export const value = 123;\n", "utf8"); + + const runtime = await loadRuntimeModule(); + const imported = await runtime.importOpenClawInternalModule(openClawRoot, ["src/candidate.ts"]); + + expect(imported).toMatchObject({ value: 123 }); + }); + + it("Given no candidates are importable, when importOpenClawInternalModule is called, then the thrown error names the attempted candidates", async () => { + const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-missing-")); + const runtime = await loadRuntimeModule(); + + await expect( + runtime.importOpenClawInternalModule(openClawRoot, ["src/missing.ts", "dist/missing.js"]), + ).rejects.toThrow( + `agent-control: openclaw internal module not found (src/missing.ts, dist/missing.js) under ${openClawRoot}`, + ); + }); +}); diff --git a/test/session-context.test.ts b/test/session-context.test.ts new file mode 100644 index 0000000..33645db --- /dev/null +++ b/test/session-context.test.ts @@ -0,0 +1,174 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { resolveSessionIdentityMock } = vi.hoisted(() => ({ + resolveSessionIdentityMock: vi.fn(), +})); + +vi.mock("../src/session-store.ts", () => ({ + resolveSessionIdentity: resolveSessionIdentityMock, +})); + +import { buildEvaluationContext } from "../src/session-context.ts"; + +function createApi(): OpenClawPluginApi { + return { + id: "agent-control-openclaw-plugin", + version: "test-version", + config: {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + on: vi.fn(), + }; +} + +beforeEach(() => { + resolveSessionIdentityMock.mockReset().mockResolvedValue({ + provider: null, + type: "unknown", + channelName: null, + dmUserName: null, + label: null, + from: null, + to: null, + accountId: null, + source: "unknown", + }); +}); + +describe("buildEvaluationContext", () => { + it("Given a Discord channel session key, when session-store metadata is unknown, then channel details are derived from the session key", async () => { + const context = await buildEvaluationContext({ + api: createApi(), + sourceAgentId: "worker-1", + state: { + sourceAgentId: "worker-1", + agentName: "base-agent:worker-1", + steps: [], + stepsHash: "hash-1", + lastSyncedStepsHash: null, + syncPromise: null, + }, + event: { + runId: "event-run", + toolCallId: "event-call", + }, + ctx: { + sessionKey: "agent:worker-1:discord:guild-1:channel-2", + }, + failClosed: false, + }); + + expect(context).toMatchObject({ + openclawAgentId: "worker-1", + channelType: "channel", + runId: "event-run", + toolCallId: "event-call", + channel: { + provider: "discord", + type: "channel", + scope: "discord:guild-1:channel-2", + source: "sessionKey", + }, + }); + }); + + it("Given session-store metadata for a direct message, when the session key points at a group scope, then the session-store provider and type win while the key scope is retained", async () => { + resolveSessionIdentityMock.mockResolvedValueOnce({ + provider: "slack", + type: "direct", + channelName: null, + dmUserName: "Alice", + label: "Alice", + from: "alice@example.com", + to: "bot@example.com", + accountId: "acct-1", + source: "sessionStore", + }); + + const context = await buildEvaluationContext({ + api: createApi(), + sourceAgentId: "worker-1", + state: { + sourceAgentId: "worker-1", + agentName: "base-agent:worker-1", + steps: [{ type: "tool", name: "shell" }], + stepsHash: "hash-1", + lastSyncedStepsHash: "hash-0", + syncPromise: null, + }, + event: {}, + ctx: { + sessionKey: "agent:worker-1:discord:group:team-room", + runId: "ctx-run", + toolCallId: "ctx-call", + }, + failClosed: true, + configuredAgentId: "configured-agent", + configuredAgentVersion: "2026.03.20", + pluginVersion: "test-version", + }); + + expect(context).toMatchObject({ + runId: "ctx-run", + toolCallId: "ctx-call", + channelType: "direct", + dmUserName: "Alice", + senderFrom: "alice@example.com", + policy: { + failClosed: true, + configuredAgentId: "configured-agent", + configuredAgentVersion: "2026.03.20", + }, + sync: { + agentName: "base-agent:worker-1", + stepCount: 1, + stepsHash: "hash-1", + lastSyncedStepsHash: "hash-0", + }, + channel: { + provider: "slack", + type: "direct", + scope: "discord:group:team-room", + source: "sessionStore+sessionKey", + dmUserName: "Alice", + from: "alice@example.com", + }, + }); + }); + + it("Given no parseable session key, when the context is built, then channel information falls back to unknown values", async () => { + const context = await buildEvaluationContext({ + api: createApi(), + sourceAgentId: "worker-1", + state: { + sourceAgentId: "worker-1", + agentName: "base-agent:worker-1", + steps: [], + stepsHash: "hash-1", + lastSyncedStepsHash: null, + syncPromise: null, + }, + event: {}, + ctx: { + sessionKey: "not-an-agent-session-key", + }, + failClosed: false, + }); + + expect(context).toMatchObject({ + channelType: "unknown", + channelName: null, + dmUserName: null, + senderFrom: null, + channel: { + provider: null, + type: "unknown", + scope: null, + source: "unknown", + }, + }); + }); +}); diff --git a/test/session-store.test.ts b/test/session-store.test.ts new file mode 100644 index 0000000..35b9624 --- /dev/null +++ b/test/session-store.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +type SessionStoreFixture = { + config?: Record; + initialStore?: Record; + throws?: boolean; +}; + +async function loadSessionStoreModule(fixture: SessionStoreFixture = {}) { + vi.resetModules(); + + let currentStore = fixture.initialStore ?? {}; + const loadConfig = vi.fn(() => fixture.config ?? {}); + const resolveStorePath = vi.fn((storePath?: string) => storePath ?? "/tmp/session-store.json"); + const loadSessionStore = vi.fn(() => currentStore); + const importOpenClawInternalModule = vi.fn(async (_openClawRoot: string, candidates: string[]) => { + if (fixture.throws) { + throw new Error("internal module load failed"); + } + if (candidates.some((candidate) => candidate.includes("sessions"))) { + return { resolveStorePath, loadSessionStore }; + } + return { loadConfig }; + }); + + vi.doMock("../src/openclaw-runtime.ts", () => ({ + getResolvedOpenClawRootDir: () => "/openclaw", + importOpenClawInternalModule, + })); + + const module = await import("../src/session-store.ts"); + return { + resolveSessionIdentity: module.resolveSessionIdentity, + mocks: { + importOpenClawInternalModule, + loadConfig, + resolveStorePath, + loadSessionStore, + setStore(store: Record) { + currentStore = store; + }, + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unmock("../src/openclaw-runtime.ts"); +}); + +describe("resolveSessionIdentity", () => { + it("Given no session key, when session identity is resolved, then an unknown identity is returned", async () => { + const { resolveSessionIdentity } = await loadSessionStoreModule(); + + await expect(resolveSessionIdentity(undefined)).resolves.toEqual({ + provider: null, + type: "unknown", + channelName: null, + dmUserName: null, + label: null, + from: null, + to: null, + accountId: null, + source: "unknown", + }); + }); + + it("Given a direct-message session entry, when session identity is resolved, then DM metadata is mapped from the store", async () => { + const { resolveSessionIdentity } = await loadSessionStoreModule({ + initialStore: { + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + from: "alice@example.com", + to: "bot@example.com", + accountId: "acct-1", + }, + displayName: "Alice Display", + }, + }, + }); + + await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toEqual({ + provider: "slack", + type: "direct", + channelName: null, + dmUserName: "Alice", + label: "Alice", + from: "alice@example.com", + to: "bot@example.com", + accountId: "acct-1", + source: "sessionStore", + }); + }); + + it("Given only a base session entry exists, when a thread-specific session key is resolved, then the base session metadata is used", async () => { + const { resolveSessionIdentity } = await loadSessionStoreModule({ + initialStore: { + "agent:worker-1:slack:channel:eng": { + origin: { + provider: "slack", + chatType: "channel", + label: "Engineering", + }, + groupChannel: "eng", + }, + }, + }); + + await expect( + resolveSessionIdentity("agent:worker-1:slack:channel:eng:thread:123"), + ).resolves.toMatchObject({ + provider: "slack", + type: "channel", + channelName: "eng", + label: "Engineering", + source: "sessionStore", + }); + }); + + it("Given the same session is resolved twice before the TTL expires, when the underlying store changes, then the cached identity is reused", async () => { + vi.useFakeTimers(); + const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ + initialStore: { + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + }, + }, + }, + }); + + const first = await resolveSessionIdentity("agent:worker-1:slack:direct:alice"); + mocks.setStore({ + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Bob", + }, + }, + }); + const second = await resolveSessionIdentity("agent:worker-1:slack:direct:alice"); + + expect(first.label).toBe("Alice"); + expect(second.label).toBe("Alice"); + expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1); + }); + + it("Given the session metadata TTL has expired, when the underlying store changes, then the refreshed identity is returned", async () => { + vi.useFakeTimers(); + const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ + initialStore: { + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + }, + }, + }, + }); + + await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ + label: "Alice", + }); + + mocks.setStore({ + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Bob", + }, + }, + }); + vi.advanceTimersByTime(2_001); + + await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ + label: "Bob", + }); + expect(mocks.loadSessionStore).toHaveBeenCalledTimes(2); + }); + + it("Given the OpenClaw session-store internals cannot be loaded, when session identity is resolved, then an unknown identity is returned", async () => { + const { resolveSessionIdentity } = await loadSessionStoreModule({ + throws: true, + }); + + await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toEqual({ + provider: null, + type: "unknown", + channelName: null, + dmUserName: null, + label: null, + from: null, + to: null, + accountId: null, + source: "unknown", + }); + }); +}); diff --git a/test/shared.test.ts b/test/shared.test.ts new file mode 100644 index 0000000..24408be --- /dev/null +++ b/test/shared.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + asPositiveInt, + asString, + formatToolArgsForLog, + hashSteps, + sanitizeToolCatalogConfig, + toJsonRecord, +} from "../src/shared.ts"; + +describe("shared utilities", () => { + it("Given a blank string, when it is normalized, then undefined is returned", () => { + expect(asString(" ")).toBeUndefined(); + }); + + it("Given a positive floating-point number, when it is normalized, then it is floored to a positive integer", () => { + expect(asPositiveInt(42.9)).toBe(42); + }); + + it("Given a non-record value, when it is serialized as a JSON record, then undefined is returned", () => { + expect(toJsonRecord(["not", "a", "record"])).toBeUndefined(); + }); + + it("Given a plugin config with plugins enabled, when the tool catalog config is sanitized, then plugins are forced off and sibling fields are preserved", () => { + expect( + sanitizeToolCatalogConfig({ + mode: "test", + plugins: { + enabled: true, + keepMe: "yes", + }, + }), + ).toEqual({ + mode: "test", + plugins: { + enabled: false, + keepMe: "yes", + }, + }); + }); + + it("Given an unserializable argument payload, when it is formatted for logs, then a stable placeholder is returned", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(formatToolArgsForLog(circular)).toBe("[unserializable]"); + }); + + it("Given two identical step arrays, when they are hashed, then they produce the same digest", () => { + const steps = [{ type: "tool" as const, name: "shell" }]; + + expect(hashSteps(steps)).toBe(hashSteps(steps)); + }); +}); diff --git a/test/tool-catalog.test.ts b/test/tool-catalog.test.ts new file mode 100644 index 0000000..3beb4af --- /dev/null +++ b/test/tool-catalog.test.ts @@ -0,0 +1,217 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +type ToolCatalogFixture = { + openClawRoot: string; + distPiToolsModule?: Record | null; + distAdapterModule?: Record | null; + sourcePiToolsModule?: Record; + sourceAdapterModule?: Record; +}; + +async function loadToolCatalogModule(fixture: ToolCatalogFixture) { + vi.resetModules(); + + const tryImportOpenClawInternalModule = vi.fn( + async (_openClawRoot: string, candidates: string[]) => { + if (candidates.some((candidate) => candidate.includes("pi-tools"))) { + return fixture.distPiToolsModule ?? null; + } + return fixture.distAdapterModule ?? null; + }, + ); + + const importOpenClawInternalModule = vi.fn( + async (_openClawRoot: string, candidates: string[]) => { + if (candidates.some((candidate) => candidate.includes("pi-tools"))) { + return fixture.sourcePiToolsModule ?? {}; + } + return fixture.sourceAdapterModule ?? {}; + }, + ); + + vi.doMock("../src/openclaw-runtime.ts", () => ({ + getResolvedOpenClawRootDir: () => fixture.openClawRoot, + tryImportOpenClawInternalModule, + importOpenClawInternalModule, + normalizeRelativeImportPath: vi.fn((fromDir: string, toFile: string) => + path.relative(fromDir, toFile), + ), + PLUGIN_ROOT_DIR: path.join(fixture.openClawRoot, "plugin"), + readPackageVersion: vi.fn(() => "1.0.0"), + safeStatMtimeMs: vi.fn(() => null), + })); + + const module = await import("../src/tool-catalog.ts"); + return { + resolveStepsForContext: module.resolveStepsForContext, + mocks: { + tryImportOpenClawInternalModule, + importOpenClawInternalModule, + }, + }; +} + +function createApi(config: Record): OpenClawPluginApi { + return { + id: "agent-control-openclaw-plugin", + version: "test-version", + config, + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + on: vi.fn(), + }; +} + +function createLogger() { + return { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + block: vi.fn(), + }; +} + +afterEach(() => { + vi.unmock("../src/openclaw-runtime.ts"); +}); + +describe("resolveStepsForContext", () => { + it("Given duplicate and invalid tool definitions, when steps are resolved, then the last valid definition wins and the synced config disables plugins", async () => { + const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); + const toToolDefinitions = vi.fn(() => [ + { + name: "shell", + label: "Shell v1", + description: "Run a shell command", + parameters: { type: "object", title: "v1" }, + }, + { + name: "shell", + label: "Shell v2", + description: "Run a newer shell command", + parameters: { type: "object", title: "v2" }, + }, + { + name: "browser", + label: "Browser", + parameters: ["not-a-record"], + }, + { + name: " ", + label: "Ignored", + }, + ]); + + const { resolveStepsForContext, mocks } = await loadToolCatalogModule({ + openClawRoot: fs.mkdtempSync(path.join(os.tmpdir(), "tool-catalog-dist-")), + distPiToolsModule: { createOpenClawCodingTools }, + distAdapterModule: { toToolDefinitions }, + }); + const logger = createLogger(); + + const steps = await resolveStepsForContext({ + api: createApi({ + plugins: { + enabled: true, + keepMe: "yes", + }, + mode: "test", + }), + logger, + sourceAgentId: "worker-1", + sessionKey: "agent:worker-1:slack:direct:alice", + sessionId: "session-1", + runId: "run-1", + }); + + expect(steps).toEqual([ + { + type: "tool", + name: "shell", + description: "Run a newer shell command", + inputSchema: { type: "object", title: "v2" }, + metadata: { label: "Shell v2" }, + }, + { + type: "tool", + name: "browser", + description: "Browser", + metadata: { label: "Browser" }, + }, + ]); + expect(createOpenClawCodingTools).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "worker-1", + sessionKey: "agent:worker-1:slack:direct:alice", + sessionId: "session-1", + runId: "run-1", + senderIsOwner: true, + config: { + plugins: { + enabled: false, + keepMe: "yes", + }, + mode: "test", + }, + }), + ); + expect(mocks.importOpenClawInternalModule).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining("resolve_steps duration_sec="), + ); + }); + + it("Given dist internals are unavailable, when steps are resolved, then the source-module fallback is used", async () => { + const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tool-catalog-source-")); + const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); + const toToolDefinitions = vi.fn(() => [ + { + name: "shell", + label: "Shell", + description: "Run a shell command", + parameters: { type: "object" }, + }, + ]); + + const { resolveStepsForContext, mocks } = await loadToolCatalogModule({ + openClawRoot, + distPiToolsModule: null, + distAdapterModule: null, + sourcePiToolsModule: { createOpenClawCodingTools }, + sourceAdapterModule: { toToolDefinitions }, + }); + + const steps = await resolveStepsForContext({ + api: createApi({}), + logger: createLogger(), + sourceAgentId: "worker-1", + }); + + expect(steps).toEqual([ + { + type: "tool", + name: "shell", + description: "Run a shell command", + inputSchema: { type: "object" }, + metadata: { label: "Shell" }, + }, + ]); + expect(mocks.importOpenClawInternalModule).toHaveBeenCalledTimes(2); + expect(mocks.importOpenClawInternalModule).toHaveBeenNthCalledWith( + 1, + openClawRoot, + ["src/agents/pi-tools.ts"], + ); + expect(mocks.importOpenClawInternalModule).toHaveBeenNthCalledWith( + 2, + openClawRoot, + ["src/agents/pi-tool-definition-adapter.ts"], + ); + }); +}); From 5a667d7985c076dbc03dafeea9f0c312df83adf1 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 11:58:09 -0700 Subject: [PATCH 5/9] Add coverage reporting and Codecov upload --- .github/workflows/lint.yml | 21 +- .gitignore | 1 + README.md | 3 + package-lock.json | 1563 +++++++++++++++++---------------- package.json | 2 + test/openclaw-runtime.test.ts | 4 +- test/session-store.test.ts | 2 +- test/tool-catalog.test.ts | 2 +- vitest.config.ts | 15 + 9 files changed, 829 insertions(+), 784 deletions(-) create mode 100644 vitest.config.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6d2b829..c8fcc0a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,5 +36,22 @@ jobs: - name: Run typecheck run: npm run typecheck - - name: Run tests - run: npm test + - name: Run coverage + run: npm run coverage + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage + + - name: Upload coverage to Codecov + if: ${{ secrets.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@v5 + with: + files: ./coverage/lcov.info + flags: unit + name: vitest-coverage + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + verbose: true diff --git a/.gitignore b/.gitignore index c2658d7..25fbf5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +coverage/ diff --git a/README.md b/README.md index a205ae7..9d7951f 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,11 @@ npm install npm run lint npm run typecheck npm test +npm run coverage ``` +Coverage reports are written to `coverage/`, including `coverage/lcov.info` for Codecov-compatible uploads. The GitHub Actions workflow will upload that report to Codecov automatically when a `CODECOV_TOKEN` secret is configured for the repository. + 3. Link it into your OpenClaw config from your OpenClaw checkout: ```bash diff --git a/package-lock.json b/package-lock.json index 9579805..43db100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@semantic-release/git": "^10.0.1", "@types/node": "^24.5.2", + "@vitest/coverage-v8": "^4.0.18", "oxlint": "^0.15.0", "semantic-release": "^25.0.3", "typescript": "^5.9.2", @@ -85,6 +86,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -95,6 +106,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -106,6 +157,40 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -496,6 +581,16 @@ "node": ">=18" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -503,6 +598,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -660,6 +783,16 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@oxlint/darwin-arm64": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-0.15.0.tgz", @@ -809,24 +942,10 @@ "node": ">=12" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "cpu": [ "arm64" ], @@ -835,12 +954,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "cpu": [ "arm64" ], @@ -849,12 +971,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "cpu": [ "x64" ], @@ -863,26 +988,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "cpu": [ "x64" ], @@ -891,26 +1005,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "cpu": [ "arm" ], @@ -919,26 +1022,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "cpu": [ "arm64" ], @@ -947,54 +1039,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "cpu": [ "ppc64" ], @@ -1003,40 +1073,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "cpu": [ "s390x" ], @@ -1045,12 +1090,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "cpu": [ "x64" ], @@ -1059,12 +1107,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "cpu": [ "x64" ], @@ -1073,26 +1124,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "cpu": [ "arm64" ], @@ -1101,40 +1141,49 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "cpu": [ "x64" ], @@ -1143,21 +1192,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", @@ -1642,6 +1687,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1684,18 +1740,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1703,13 +1790,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1718,7 +1805,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -1730,9 +1817,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1743,13 +1830,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -1757,13 +1844,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1772,9 +1860,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -1782,13 +1870,14 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1914,6 +2003,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -2301,6 +2409,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2407,6 +2522,16 @@ "node": ">=4.0.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2607,9 +2732,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -3027,6 +3152,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3281,6 +3413,45 @@ "node": "^18.17 || >=20.6.1" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -3354,6 +3525,267 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3474,6 +3906,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-asynchronous": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", @@ -3505,6 +3949,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -6290,49 +6750,38 @@ "node": ">=8" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, "node_modules/safe-buffer": { @@ -6654,9 +7103,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -6988,6 +7437,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -7144,24 +7601,23 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -7170,14 +7626,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -7186,13 +7643,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -7218,489 +7678,32 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -7716,12 +7719,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -7750,6 +7754,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 7caf515..8ffd495 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "lint:fix": "oxlint . --fix", "typecheck": "tsc --noEmit", "test": "vitest run", + "coverage": "vitest run --coverage", "release": "semantic-release" }, "dependencies": { @@ -39,6 +40,7 @@ "jiti": "^2.6.1" }, "devDependencies": { + "@vitest/coverage-v8": "^4.0.18", "@semantic-release/git": "^10.0.1", "@types/node": "^24.5.2", "oxlint": "^0.15.0", diff --git a/test/openclaw-runtime.test.ts b/test/openclaw-runtime.test.ts index 8e4f4a9..3f1c122 100644 --- a/test/openclaw-runtime.test.ts +++ b/test/openclaw-runtime.test.ts @@ -25,8 +25,8 @@ async function loadRuntimeModule(options: { packageResolve?: string | Error } = afterEach(() => { process.chdir(originalCwd); - vi.unmock("node:module"); - vi.unmock("../src/openclaw-runtime.ts"); + vi.doUnmock("node:module"); + vi.doUnmock("../src/openclaw-runtime.ts"); }); describe("openclaw runtime helpers", () => { diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 35b9624..e0a266f 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -45,7 +45,7 @@ async function loadSessionStoreModule(fixture: SessionStoreFixture = {}) { afterEach(() => { vi.useRealTimers(); - vi.unmock("../src/openclaw-runtime.ts"); + vi.doUnmock("../src/openclaw-runtime.ts"); }); describe("resolveSessionIdentity", () => { diff --git a/test/tool-catalog.test.ts b/test/tool-catalog.test.ts index 3beb4af..9a87e4c 100644 --- a/test/tool-catalog.test.ts +++ b/test/tool-catalog.test.ts @@ -78,7 +78,7 @@ function createLogger() { } afterEach(() => { - vi.unmock("../src/openclaw-runtime.ts"); + vi.doUnmock("../src/openclaw-runtime.ts"); }); describe("resolveStepsForContext", () => { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7e50ed5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + fileParallelism: false, + coverage: { + provider: "v8", + all: true, + reporter: ["text", "html", "json-summary", "lcov"], + reportsDirectory: "./coverage", + include: ["index.ts", "src/**/*.ts"], + exclude: ["index.ts", "src/types.ts", "test/**/*.ts", "types/**/*.d.ts"], + }, + }, +}); From 34656fdd53192870853b8641a8505220f73e419e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 12:21:41 -0700 Subject: [PATCH 6/9] Add repo guidance and Given/When/Then test comments --- AGENTS.md | 66 ++++++++++++++++++++++++++ test/agent-control-plugin.test.ts | 70 ++++++++++++++++++++++------ test/logging.test.ts | 63 ++++++++++++++++++++----- test/openclaw-runtime.test.ts | 66 ++++++++++++++++++++------ test/session-context.test.ts | 33 +++++++++---- test/session-store.test.ts | 48 ++++++++++++++----- test/shared.test.ts | 77 +++++++++++++++++++++++-------- test/tool-catalog.test.ts | 10 +++- 8 files changed, 350 insertions(+), 83 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..118f1e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# AGENTS.md + +## Repo Scope + +This repository contains the Agent Control plugin for OpenClaw. It is a TypeScript ESM project that ships source files directly; there is no separate build step in normal development. + +## Local Verification + +Run the full local verification stack before finishing non-trivial changes: + +```bash +npm run lint +npm run typecheck +npm test +``` + +When the change affects tests, coverage, or CI behavior, also run: + +```bash +npm run coverage +``` + +Coverage output is written to `coverage/`, including `coverage/lcov.info` for Codecov-compatible uploads. + +## Testing Conventions + +- Prefer behavioral tests over implementation-detail tests. +- Write test names as concise behavioral summaries. +- Express Given/When/Then structure as code comments inside the test body. +- Use Vitest for unit and integration-style tests. +- Assert externally visible outcomes first: return values, registered hooks, emitted logs, blocked tool calls, resolved context, and client calls. +- Mock boundary dependencies such as `agent-control`, session/context helpers, and runtime-loading edges when needed, but keep the assertions focused on plugin behavior. +- When adding a new branch in plugin logic, add or update tests in the corresponding `test/*.test.ts` file. + +Examples of the preferred naming style: + +- `it("defaults to warn", () => { ... })` +- `it("blocks the tool call when fail-closed sync fails", async () => { ... })` + +## Project Conventions + +- Keep imports ESM-compatible and include the `.ts` suffix for local TypeScript imports, matching the current codebase style. +- This repo uses `oxlint` for linting and `tsc --noEmit` for semantic typechecking. `npm run lint` is not a substitute for `npm run typecheck`. +- Preserve the plugin's quiet default behavior. New logs should fit the existing `logLevel` model: + - `warn`: warnings, errors, and block events + - `info`: important lifecycle events + - `debug`: verbose diagnostics + +## When Changing Config or User-Facing Behavior + +Keep these files aligned when config shape or documented behavior changes: + +- `src/types.ts` +- `openclaw.plugin.json` +- `README.md` +- relevant tests under `test/` + +If a change affects CI expectations or coverage behavior, also update: + +- `.github/workflows/lint.yml` +- `package.json` +- `vitest.config.ts` + +## CI Expectations + +The main GitHub Actions workflow is `.github/workflows/lint.yml`. Changes to scripts or coverage generation should keep local commands and CI steps in sync. diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index f5574f1..81e67b7 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -121,23 +121,29 @@ beforeEach(() => { }); describe("agent-control plugin logging and blocking", () => { - it("Given the plugin is disabled, when it is registered, then it does not initialize the client or hooks", () => { + it("skips initialization when the plugin is disabled", () => { + // Given const api = createMockApi({ enabled: false, serverUrl: "http://localhost:8000", }); + // When register(api.api); + // Then expect(clientMocks.init).not.toHaveBeenCalled(); expect(api.handlers.size).toBe(0); }); - it("Given no server URL is configured, when the plugin is registered, then it warns and skips hook registration", () => { + it("warns and skips hook registration when no server URL is configured", () => { + // Given const api = createMockApi({}); + // When register(api.api); + // Then expect(clientMocks.init).not.toHaveBeenCalled(); expect(api.handlers.size).toBe(0); expect(api.warn).toHaveBeenCalledWith( @@ -145,20 +151,24 @@ describe("agent-control plugin logging and blocking", () => { ); }); - it("Given an invalid configured agent ID, when the plugin is registered, then it warns about the invalid UUID", () => { + it("warns when the configured agent ID is not a UUID", () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", agentId: "not-a-uuid", }); + // When register(api.api); + // Then expect(api.warn).toHaveBeenCalledWith( "agent-control: configured agentId is not a UUID: not-a-uuid", ); }); - it("Given warn mode, when an unsafe evaluation occurs, then only the block event is logged", async () => { + it("only logs the block event in warn mode for unsafe evaluations", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -168,9 +178,11 @@ describe("agent-control plugin logging and blocking", () => { reason: "denied by policy", }); + // When register(api.api); const result = await runBeforeToolCall(api); + // Then expect(result).toEqual({ block: true, blockReason: USER_BLOCK_MESSAGE, @@ -182,16 +194,19 @@ describe("agent-control plugin logging and blocking", () => { expect(clientMocks.evaluationEvaluate).toHaveBeenCalledOnce(); }); - it("Given info mode, when warmup and a tool evaluation run, then lifecycle logs are emitted without debug traces", async () => { + it("emits lifecycle logs without debug traces in info mode", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", logLevel: "info", }); + // When register(api.api); await runGatewayStart(api); await runBeforeToolCall(api); + // Then const messages = api.info.mock.calls.map(([message]) => String(message)); expect(messages.some((message) => message.includes("client_init"))).toBe(true); expect(messages.some((message) => message.includes("gateway_boot_warmup started"))).toBe(true); @@ -201,21 +216,25 @@ describe("agent-control plugin logging and blocking", () => { expect(messages.some((message) => message.includes("evaluated agent="))).toBe(false); }); - it("Given the deprecated debug flag, when a tool evaluation runs, then verbose debug traces are emitted", async () => { + it("emits verbose traces when the deprecated debug flag is enabled", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", debug: true, }); + // When register(api.api); await runBeforeToolCall(api); + // Then const messages = api.info.mock.calls.map(([message]) => String(message)); expect(messages.some((message) => message.includes("before_tool_call entered"))).toBe(true); expect(messages.some((message) => message.includes("phase=evaluate"))).toBe(true); }); - it("Given fail-closed mode, when step resolution fails, then the tool call is blocked before evaluation", async () => { + it("blocks the tool call before evaluation when fail-closed sync fails", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", failClosed: true, @@ -223,9 +242,11 @@ describe("agent-control plugin logging and blocking", () => { resolveStepsForContextMock.mockRejectedValueOnce(new Error("resolver exploded")); + // When register(api.api); const result = await runBeforeToolCall(api); + // Then expect(result).toEqual({ block: true, blockReason: USER_BLOCK_MESSAGE, @@ -240,16 +261,19 @@ describe("agent-control plugin logging and blocking", () => { ); }); - it("Given a fixed configured agent ID, when a source agent evaluates a tool, then the base agent name is used without a source suffix", async () => { + it("uses the base agent name when a fixed configured agent ID is present", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", agentId: VALID_AGENT_ID, agentName: "base-agent", }); + // When register(api.api); await runBeforeToolCall(api, {}, { agentId: "worker-1" }); + // Then expect(clientMocks.agentsInit).toHaveBeenCalledWith( expect.objectContaining({ agent: expect.objectContaining({ @@ -262,15 +286,18 @@ describe("agent-control plugin logging and blocking", () => { ); }); - it("Given no configured agent ID, when a source agent evaluates a tool, then the source agent ID is appended to the base agent name", async () => { + it("appends the source agent ID when no configured agent ID is present", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", agentName: "base-agent", }); + // When register(api.api); await runBeforeToolCall(api, {}, { agentId: "worker-1" }); + // Then expect(clientMocks.agentsInit).toHaveBeenCalledWith( expect.objectContaining({ agent: expect.objectContaining({ @@ -280,15 +307,18 @@ describe("agent-control plugin logging and blocking", () => { ); }); - it("Given gateway warmup has already started, when gateway_start fires again, then the warmup work is reused", async () => { + it("reuses warmup work across repeated gateway_start events", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", }); + // When register(api.api); await runGatewayStart(api); await runGatewayStart(api); + // Then expect(resolveStepsForContextMock).toHaveBeenCalledTimes(1); expect(resolveStepsForContextMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -297,13 +327,15 @@ describe("agent-control plugin logging and blocking", () => { ); }); - it("Given two concurrent tool calls for the same source agent, when sync is already in flight, then Agent Control is initialized only once", async () => { + it("deduplicates concurrent syncs for the same source agent", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", }); const syncDeferred = createDeferred(); clientMocks.agentsInit.mockImplementation(() => syncDeferred.promise); + // When register(api.api); const first = runBeforeToolCall(api); @@ -316,23 +348,28 @@ describe("agent-control plugin logging and blocking", () => { syncDeferred.resolve(undefined); await Promise.all([first, second]); + // Then expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); }); - it("Given the synced step catalog is unchanged, when the same source agent evaluates another tool, then the second call skips resyncing the agent", async () => { + it("skips resyncing when the step catalog has not changed", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", }); + // When register(api.api); await runBeforeToolCall(api); await runBeforeToolCall(api); + // Then expect(clientMocks.agentsInit).toHaveBeenCalledTimes(1); expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); }); - it("Given duplicate deny controls in the evaluation response, when the tool call is blocked, then the block reason lists each control once", async () => { + it("deduplicates deny controls in the block reason", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -347,15 +384,18 @@ describe("agent-control plugin logging and blocking", () => { errors: null, }); + // When register(api.api); await runBeforeToolCall(api); + // Then const message = String(api.warn.mock.calls[0]?.[0]); expect(message).toContain("alpha, beta"); expect(message).not.toContain("alpha, alpha"); }); - it("Given no policy reason or deny controls are returned, when the tool call is blocked, then the generic block reason is logged", async () => { + it("logs the generic block reason when no policy details are returned", async () => { + // Given const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -367,9 +407,11 @@ describe("agent-control plugin logging and blocking", () => { errors: null, }); + // When register(api.api); await runBeforeToolCall(api); + // Then expect(api.warn).toHaveBeenCalledWith( expect.stringContaining("reason=[agent-control] blocked by policy evaluation"), ); diff --git a/test/logging.test.ts b/test/logging.test.ts index d945336..f569d44 100644 --- a/test/logging.test.ts +++ b/test/logging.test.ts @@ -2,59 +2,98 @@ import { describe, expect, it, vi } from "vitest"; import { createPluginLogger, resolveLogLevel } from "../src/logging.ts"; describe("resolveLogLevel", () => { - it("Given no logging configuration, when the log level is resolved, then warn is used", () => { - expect(resolveLogLevel({})).toBe("warn"); + it("defaults to warn", () => { + // Given + const config = {}; + + // When + const level = resolveLogLevel(config); + + // Then + expect(level).toBe("warn"); }); - it("Given an explicit log level, when the log level is resolved, then the configured level is used", () => { - expect(resolveLogLevel({ logLevel: "info" })).toBe("info"); - expect(resolveLogLevel({ logLevel: "debug" })).toBe("debug"); + it("uses an explicit configured level", () => { + // Given + const infoConfig = { logLevel: "info" } as const; + const debugConfig = { logLevel: "debug" } as const; + + // When + const infoLevel = resolveLogLevel(infoConfig); + const debugLevel = resolveLogLevel(debugConfig); + + // Then + expect(infoLevel).toBe("info"); + expect(debugLevel).toBe("debug"); }); - it("Given both logLevel and the deprecated debug flag, when the log level is resolved, then logLevel wins", () => { - expect(resolveLogLevel({ logLevel: "warn", debug: true })).toBe("warn"); + it("prefers logLevel over the deprecated debug flag", () => { + // Given + const config = { logLevel: "warn", debug: true } as const; + + // When + const level = resolveLogLevel(config); + + // Then + expect(level).toBe("warn"); }); - it("Given an invalid logLevel and debug=true, when the log level is resolved, then debug is used as a compatibility fallback", () => { - expect(resolveLogLevel({ logLevel: "verbose" as never, debug: true })).toBe("debug"); + it("falls back to debug for deprecated compatibility", () => { + // Given + const config = { logLevel: "verbose" as never, debug: true }; + + // When + const level = resolveLogLevel(config); + + // Then + expect(level).toBe("debug"); }); }); describe("createPluginLogger", () => { - it("Given warn mode, when info and debug messages are emitted, then only warning-class messages are forwarded", () => { + it("suppresses info and debug output in warn mode", () => { + // Given const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "warn"); + // When logger.info("info"); logger.debug("debug"); logger.warn("warn"); logger.block("block"); + // Then expect(info).not.toHaveBeenCalled(); expect(warn.mock.calls).toEqual([["warn"], ["block"]]); }); - it("Given info mode, when info and debug messages are emitted, then lifecycle info is forwarded and debug traces stay suppressed", () => { + it("emits info but not debug output in info mode", () => { + // Given const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "info"); + // When logger.info("info"); logger.debug("debug"); + // Then expect(info.mock.calls).toEqual([["info"]]); expect(warn).not.toHaveBeenCalled(); }); - it("Given debug mode, when info and debug messages are emitted, then both are forwarded", () => { + it("emits info and debug output in debug mode", () => { + // Given const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "debug"); + // When logger.info("info"); logger.debug("debug"); + // Then expect(info.mock.calls).toEqual([["info"], ["debug"]]); expect(warn).not.toHaveBeenCalled(); }); diff --git a/test/openclaw-runtime.test.ts b/test/openclaw-runtime.test.ts index 3f1c122..e362739 100644 --- a/test/openclaw-runtime.test.ts +++ b/test/openclaw-runtime.test.ts @@ -30,7 +30,8 @@ afterEach(() => { }); describe("openclaw runtime helpers", () => { - it("Given a valid package.json, when package fields are read, then name and version are returned", async () => { + it("reads the package name and version from package.json", async () => { + // Given const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-pkg-")); const packageJsonPath = path.join(tempDir, "package.json"); fs.writeFileSync( @@ -41,18 +42,30 @@ describe("openclaw runtime helpers", () => { const runtime = await loadRuntimeModule(); - expect(runtime.readPackageName(packageJsonPath)).toBe("openclaw"); - expect(runtime.readPackageVersion(packageJsonPath)).toBe("1.2.3"); + // When + const name = runtime.readPackageName(packageJsonPath); + const version = runtime.readPackageVersion(packageJsonPath); + + // Then + expect(name).toBe("openclaw"); + expect(version).toBe("1.2.3"); }); - it("Given source and target files in sibling directories, when the import path is normalized, then a relative posix path with a leading dot is returned", async () => { + it("normalizes relative import paths with a leading dot", async () => { + // Given const runtime = await loadRuntimeModule(); - expect(runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/c/tool.ts")).toBe("../c/tool.ts"); - expect(runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/b/tool.ts")).toBe("./tool.ts"); + // When + const siblingImport = runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/c/tool.ts"); + const sameDirImport = runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/b/tool.ts"); + + // Then + expect(siblingImport).toBe("../c/tool.ts"); + expect(sameDirImport).toBe("./tool.ts"); }); - it("Given package resolution is unavailable but the current working directory is inside an OpenClaw checkout, when the root dir is resolved, then the checkout root is found from cwd", async () => { + it("finds the OpenClaw root from cwd when package resolution is unavailable", async () => { + // Given const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-root-")); fs.writeFileSync( path.join(openClawRoot, "package.json"), @@ -67,10 +80,15 @@ describe("openclaw runtime helpers", () => { packageResolve: new Error("not found"), }); - expect(runtime.getResolvedOpenClawRootDir()).toBe(fs.realpathSync(openClawRoot)); + // When + const resolvedRoot = runtime.getResolvedOpenClawRootDir(); + + // Then + expect(resolvedRoot).toBe(fs.realpathSync(openClawRoot)); }); - it("Given package resolution is unavailable and no OpenClaw checkout can be found, when the root dir is resolved, then a helpful error is thrown", async () => { + it("throws a helpful error when no OpenClaw root can be found", async () => { + // Given const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-")); process.chdir(tempDir); @@ -78,42 +96,62 @@ describe("openclaw runtime helpers", () => { packageResolve: new Error("not found"), }); - expect(() => runtime.getResolvedOpenClawRootDir()).toThrow( + // When + const resolveRoot = () => runtime.getResolvedOpenClawRootDir(); + + // Then + expect(resolveRoot).toThrow( "agent-control: unable to resolve openclaw package root for internal tool schema access", ); }); - it("Given a JavaScript candidate exists, when tryImportOpenClawInternalModule is called, then the first importable candidate is returned", async () => { + it("returns the first importable JavaScript candidate", async () => { + // Given const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-js-")); fs.mkdirSync(path.join(openClawRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(openClawRoot, "dist", "candidate.mjs"), "export const value = 123;\n", "utf8"); const runtime = await loadRuntimeModule(); + + // When const imported = await runtime.tryImportOpenClawInternalModule(openClawRoot, [ "dist/missing.mjs", "dist/candidate.mjs", ]); + // Then expect(imported).toMatchObject({ value: 123 }); }); - it("Given a TypeScript candidate exists, when importOpenClawInternalModule is called, then the module is loaded through jiti", async () => { + it("loads a TypeScript candidate through jiti", async () => { + // Given const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-ts-")); fs.mkdirSync(path.join(openClawRoot, "src"), { recursive: true }); fs.writeFileSync(path.join(openClawRoot, "src", "candidate.ts"), "export const value = 123;\n", "utf8"); const runtime = await loadRuntimeModule(); + + // When const imported = await runtime.importOpenClawInternalModule(openClawRoot, ["src/candidate.ts"]); + // Then expect(imported).toMatchObject({ value: 123 }); }); - it("Given no candidates are importable, when importOpenClawInternalModule is called, then the thrown error names the attempted candidates", async () => { + it("names attempted candidates when no module can be imported", async () => { + // Given const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-missing-")); const runtime = await loadRuntimeModule(); + // When + const importPromise = runtime.importOpenClawInternalModule(openClawRoot, [ + "src/missing.ts", + "dist/missing.js", + ]); + + // Then await expect( - runtime.importOpenClawInternalModule(openClawRoot, ["src/missing.ts", "dist/missing.js"]), + importPromise, ).rejects.toThrow( `agent-control: openclaw internal module not found (src/missing.ts, dist/missing.js) under ${openClawRoot}`, ); diff --git a/test/session-context.test.ts b/test/session-context.test.ts index 33645db..3dde077 100644 --- a/test/session-context.test.ts +++ b/test/session-context.test.ts @@ -39,8 +39,9 @@ beforeEach(() => { }); describe("buildEvaluationContext", () => { - it("Given a Discord channel session key, when session-store metadata is unknown, then channel details are derived from the session key", async () => { - const context = await buildEvaluationContext({ + it("derives channel details from the session key when store metadata is unknown", async () => { + // Given + const request = { api: createApi(), sourceAgentId: "worker-1", state: { @@ -59,8 +60,12 @@ describe("buildEvaluationContext", () => { sessionKey: "agent:worker-1:discord:guild-1:channel-2", }, failClosed: false, - }); + }; + + // When + const context = await buildEvaluationContext(request); + // Then expect(context).toMatchObject({ openclawAgentId: "worker-1", channelType: "channel", @@ -75,7 +80,8 @@ describe("buildEvaluationContext", () => { }); }); - it("Given session-store metadata for a direct message, when the session key points at a group scope, then the session-store provider and type win while the key scope is retained", async () => { + it("prefers session-store provider and type while retaining the key scope", async () => { + // Given resolveSessionIdentityMock.mockResolvedValueOnce({ provider: "slack", type: "direct", @@ -88,7 +94,7 @@ describe("buildEvaluationContext", () => { source: "sessionStore", }); - const context = await buildEvaluationContext({ + const request = { api: createApi(), sourceAgentId: "worker-1", state: { @@ -109,8 +115,12 @@ describe("buildEvaluationContext", () => { configuredAgentId: "configured-agent", configuredAgentVersion: "2026.03.20", pluginVersion: "test-version", - }); + }; + // When + const context = await buildEvaluationContext(request); + + // Then expect(context).toMatchObject({ runId: "ctx-run", toolCallId: "ctx-call", @@ -139,8 +149,9 @@ describe("buildEvaluationContext", () => { }); }); - it("Given no parseable session key, when the context is built, then channel information falls back to unknown values", async () => { - const context = await buildEvaluationContext({ + it("falls back to unknown channel information for an unparseable session key", async () => { + // Given + const request = { api: createApi(), sourceAgentId: "worker-1", state: { @@ -156,8 +167,12 @@ describe("buildEvaluationContext", () => { sessionKey: "not-an-agent-session-key", }, failClosed: false, - }); + }; + + // When + const context = await buildEvaluationContext(request); + // Then expect(context).toMatchObject({ channelType: "unknown", channelName: null, diff --git a/test/session-store.test.ts b/test/session-store.test.ts index e0a266f..1248f2c 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -49,10 +49,15 @@ afterEach(() => { }); describe("resolveSessionIdentity", () => { - it("Given no session key, when session identity is resolved, then an unknown identity is returned", async () => { + it("returns an unknown identity when no session key is provided", async () => { + // Given const { resolveSessionIdentity } = await loadSessionStoreModule(); - await expect(resolveSessionIdentity(undefined)).resolves.toEqual({ + // When + const identityPromise = resolveSessionIdentity(undefined); + + // Then + await expect(identityPromise).resolves.toEqual({ provider: null, type: "unknown", channelName: null, @@ -65,7 +70,8 @@ describe("resolveSessionIdentity", () => { }); }); - it("Given a direct-message session entry, when session identity is resolved, then DM metadata is mapped from the store", async () => { + it("maps direct-message metadata from the session store", async () => { + // Given const { resolveSessionIdentity } = await loadSessionStoreModule({ initialStore: { "agent:worker-1:slack:direct:alice": { @@ -82,7 +88,11 @@ describe("resolveSessionIdentity", () => { }, }); - await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toEqual({ + // When + const identityPromise = resolveSessionIdentity("agent:worker-1:slack:direct:alice"); + + // Then + await expect(identityPromise).resolves.toEqual({ provider: "slack", type: "direct", channelName: null, @@ -95,7 +105,8 @@ describe("resolveSessionIdentity", () => { }); }); - it("Given only a base session entry exists, when a thread-specific session key is resolved, then the base session metadata is used", async () => { + it("reuses base session metadata for thread-specific keys", async () => { + // Given const { resolveSessionIdentity } = await loadSessionStoreModule({ initialStore: { "agent:worker-1:slack:channel:eng": { @@ -109,9 +120,11 @@ describe("resolveSessionIdentity", () => { }, }); - await expect( - resolveSessionIdentity("agent:worker-1:slack:channel:eng:thread:123"), - ).resolves.toMatchObject({ + // When + const identityPromise = resolveSessionIdentity("agent:worker-1:slack:channel:eng:thread:123"); + + // Then + await expect(identityPromise).resolves.toMatchObject({ provider: "slack", type: "channel", channelName: "eng", @@ -120,7 +133,8 @@ describe("resolveSessionIdentity", () => { }); }); - it("Given the same session is resolved twice before the TTL expires, when the underlying store changes, then the cached identity is reused", async () => { + it("reuses the cached identity before the TTL expires", async () => { + // Given vi.useFakeTimers(); const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ initialStore: { @@ -134,6 +148,7 @@ describe("resolveSessionIdentity", () => { }, }); + // When const first = await resolveSessionIdentity("agent:worker-1:slack:direct:alice"); mocks.setStore({ "agent:worker-1:slack:direct:alice": { @@ -146,12 +161,14 @@ describe("resolveSessionIdentity", () => { }); const second = await resolveSessionIdentity("agent:worker-1:slack:direct:alice"); + // Then expect(first.label).toBe("Alice"); expect(second.label).toBe("Alice"); expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1); }); - it("Given the session metadata TTL has expired, when the underlying store changes, then the refreshed identity is returned", async () => { + it("refreshes the identity after the TTL expires", async () => { + // Given vi.useFakeTimers(); const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ initialStore: { @@ -165,6 +182,7 @@ describe("resolveSessionIdentity", () => { }, }); + // When await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ label: "Alice", }); @@ -180,18 +198,24 @@ describe("resolveSessionIdentity", () => { }); vi.advanceTimersByTime(2_001); + // Then await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ label: "Bob", }); expect(mocks.loadSessionStore).toHaveBeenCalledTimes(2); }); - it("Given the OpenClaw session-store internals cannot be loaded, when session identity is resolved, then an unknown identity is returned", async () => { + it("returns an unknown identity when session-store internals cannot be loaded", async () => { + // Given const { resolveSessionIdentity } = await loadSessionStoreModule({ throws: true, }); - await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toEqual({ + // When + const identityPromise = resolveSessionIdentity("agent:worker-1:slack:direct:alice"); + + // Then + await expect(identityPromise).resolves.toEqual({ provider: null, type: "unknown", channelName: null, diff --git a/test/shared.test.ts b/test/shared.test.ts index 24408be..36f2934 100644 --- a/test/shared.test.ts +++ b/test/shared.test.ts @@ -9,28 +9,54 @@ import { } from "../src/shared.ts"; describe("shared utilities", () => { - it("Given a blank string, when it is normalized, then undefined is returned", () => { - expect(asString(" ")).toBeUndefined(); + it("returns undefined for a blank string", () => { + // Given + const value = " "; + + // When + const normalized = asString(value); + + // Then + expect(normalized).toBeUndefined(); }); - it("Given a positive floating-point number, when it is normalized, then it is floored to a positive integer", () => { - expect(asPositiveInt(42.9)).toBe(42); + it("floors a positive floating-point number", () => { + // Given + const value = 42.9; + + // When + const normalized = asPositiveInt(value); + + // Then + expect(normalized).toBe(42); }); - it("Given a non-record value, when it is serialized as a JSON record, then undefined is returned", () => { - expect(toJsonRecord(["not", "a", "record"])).toBeUndefined(); + it("returns undefined for a non-record JSON value", () => { + // Given + const value = ["not", "a", "record"]; + + // When + const record = toJsonRecord(value); + + // Then + expect(record).toBeUndefined(); }); - it("Given a plugin config with plugins enabled, when the tool catalog config is sanitized, then plugins are forced off and sibling fields are preserved", () => { - expect( - sanitizeToolCatalogConfig({ - mode: "test", - plugins: { - enabled: true, - keepMe: "yes", - }, - }), - ).toEqual({ + it("forces plugins off while preserving sibling config", () => { + // Given + const config = { + mode: "test", + plugins: { + enabled: true, + keepMe: "yes", + }, + }; + + // When + const sanitized = sanitizeToolCatalogConfig(config); + + // Then + expect(sanitized).toEqual({ mode: "test", plugins: { enabled: false, @@ -39,16 +65,27 @@ describe("shared utilities", () => { }); }); - it("Given an unserializable argument payload, when it is formatted for logs, then a stable placeholder is returned", () => { + it("returns a stable placeholder for unserializable arguments", () => { + // Given const circular: { self?: unknown } = {}; circular.self = circular; - expect(formatToolArgsForLog(circular)).toBe("[unserializable]"); + // When + const formatted = formatToolArgsForLog(circular); + + // Then + expect(formatted).toBe("[unserializable]"); }); - it("Given two identical step arrays, when they are hashed, then they produce the same digest", () => { + it("produces the same digest for identical steps", () => { + // Given const steps = [{ type: "tool" as const, name: "shell" }]; - expect(hashSteps(steps)).toBe(hashSteps(steps)); + // When + const first = hashSteps(steps); + const second = hashSteps(steps); + + // Then + expect(first).toBe(second); }); }); diff --git a/test/tool-catalog.test.ts b/test/tool-catalog.test.ts index 9a87e4c..50e7755 100644 --- a/test/tool-catalog.test.ts +++ b/test/tool-catalog.test.ts @@ -82,7 +82,8 @@ afterEach(() => { }); describe("resolveStepsForContext", () => { - it("Given duplicate and invalid tool definitions, when steps are resolved, then the last valid definition wins and the synced config disables plugins", async () => { + it("deduplicates definitions and disables plugins in synced config", async () => { + // Given const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); const toToolDefinitions = vi.fn(() => [ { @@ -115,6 +116,7 @@ describe("resolveStepsForContext", () => { }); const logger = createLogger(); + // When const steps = await resolveStepsForContext({ api: createApi({ plugins: { @@ -130,6 +132,7 @@ describe("resolveStepsForContext", () => { runId: "run-1", }); + // Then expect(steps).toEqual([ { type: "tool", @@ -167,7 +170,8 @@ describe("resolveStepsForContext", () => { ); }); - it("Given dist internals are unavailable, when steps are resolved, then the source-module fallback is used", async () => { + it("falls back to source modules when dist internals are unavailable", async () => { + // Given const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tool-catalog-source-")); const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); const toToolDefinitions = vi.fn(() => [ @@ -187,12 +191,14 @@ describe("resolveStepsForContext", () => { sourceAdapterModule: { toToolDefinitions }, }); + // When const steps = await resolveStepsForContext({ api: createApi({}), logger: createLogger(), sourceAgentId: "worker-1", }); + // Then expect(steps).toEqual([ { type: "tool", From 05689dfcd477b58d15b41d5bbac3ab3015104070 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 12:23:47 -0700 Subject: [PATCH 7/9] Fix Codecov workflow guard --- .github/workflows/lint.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c8fcc0a..54669f6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -46,12 +46,14 @@ jobs: path: coverage - name: Upload coverage to Codecov - if: ${{ secrets.CODECOV_TOKEN != '' }} + if: ${{ env.CODECOV_TOKEN != '' }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} uses: codecov/codecov-action@v5 with: files: ./coverage/lcov.info flags: unit name: vitest-coverage - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ env.CODECOV_TOKEN }} fail_ci_if_error: false verbose: true From 98e721fcba592053f0e28d7ef9769a2e87e9b5de Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 12:27:52 -0700 Subject: [PATCH 8/9] Make Given/When/Then comments descriptive --- AGENTS.md | 7 +++ test/agent-control-plugin.test.ts | 84 +++++++++++++++---------------- test/logging.test.ts | 42 ++++++++-------- test/openclaw-runtime.test.ts | 42 ++++++++-------- test/session-context.test.ts | 18 +++---- test/session-store.test.ts | 36 ++++++------- test/shared.test.ts | 36 ++++++------- test/tool-catalog.test.ts | 12 ++--- 8 files changed, 142 insertions(+), 135 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 118f1e0..c13a0b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Coverage output is written to `coverage/`, including `coverage/lcov.info` for Co - Prefer behavioral tests over implementation-detail tests. - Write test names as concise behavioral summaries. - Express Given/When/Then structure as code comments inside the test body. +- Make each Given/When/Then comment descriptive. Do not use placeholder comments like `// Given`, `// When`, or `// Then` by themselves. - Use Vitest for unit and integration-style tests. - Assert externally visible outcomes first: return values, registered hooks, emitted logs, blocked tool calls, resolved context, and client calls. - Mock boundary dependencies such as `agent-control`, session/context helpers, and runtime-loading edges when needed, but keep the assertions focused on plugin behavior. @@ -37,6 +38,12 @@ Examples of the preferred naming style: - `it("defaults to warn", () => { ... })` - `it("blocks the tool call when fail-closed sync fails", async () => { ... })` +Examples of the preferred comment style: + +- `// Given no logging configuration is provided` +- `// When the effective log level is resolved` +- `// Then warn mode is selected by default` + ## Project Conventions - Keep imports ESM-compatible and include the `.ts` suffix for local TypeScript imports, matching the current codebase style. diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index 81e67b7..92022f3 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -122,28 +122,28 @@ beforeEach(() => { describe("agent-control plugin logging and blocking", () => { it("skips initialization when the plugin is disabled", () => { - // Given + // Given plugin configuration with the plugin explicitly disabled const api = createMockApi({ enabled: false, serverUrl: "http://localhost:8000", }); - // When + // When the plugin is registered with the OpenClaw API register(api.api); - // Then + // Then no client initialization or hook registration occurs expect(clientMocks.init).not.toHaveBeenCalled(); expect(api.handlers.size).toBe(0); }); it("warns and skips hook registration when no server URL is configured", () => { - // Given + // Given plugin configuration without an Agent Control server URL const api = createMockApi({}); - // When + // When the plugin is registered register(api.api); - // Then + // Then registration is skipped and a warning is emitted expect(clientMocks.init).not.toHaveBeenCalled(); expect(api.handlers.size).toBe(0); expect(api.warn).toHaveBeenCalledWith( @@ -152,23 +152,23 @@ describe("agent-control plugin logging and blocking", () => { }); it("warns when the configured agent ID is not a UUID", () => { - // Given + // Given plugin configuration with an invalid configured agent ID const api = createMockApi({ serverUrl: "http://localhost:8000", agentId: "not-a-uuid", }); - // When + // When the plugin is registered register(api.api); - // Then + // Then a UUID validation warning is emitted expect(api.warn).toHaveBeenCalledWith( "agent-control: configured agentId is not a UUID: not-a-uuid", ); }); it("only logs the block event in warn mode for unsafe evaluations", async () => { - // Given + // Given warn-level logging and an unsafe policy evaluation response const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -178,11 +178,11 @@ describe("agent-control plugin logging and blocking", () => { reason: "denied by policy", }); - // When + // When the plugin evaluates a tool call register(api.api); const result = await runBeforeToolCall(api); - // Then + // Then the tool call is blocked and only the block event is logged expect(result).toEqual({ block: true, blockReason: USER_BLOCK_MESSAGE, @@ -195,18 +195,18 @@ describe("agent-control plugin logging and blocking", () => { }); it("emits lifecycle logs without debug traces in info mode", async () => { - // Given + // Given info-level logging for a plugin that can warm up and evaluate tools const api = createMockApi({ serverUrl: "http://localhost:8000", logLevel: "info", }); - // When + // When gateway warmup and one tool evaluation are executed register(api.api); await runGatewayStart(api); await runBeforeToolCall(api); - // Then + // Then lifecycle logs are emitted without low-level debug traces const messages = api.info.mock.calls.map(([message]) => String(message)); expect(messages.some((message) => message.includes("client_init"))).toBe(true); expect(messages.some((message) => message.includes("gateway_boot_warmup started"))).toBe(true); @@ -217,24 +217,24 @@ describe("agent-control plugin logging and blocking", () => { }); it("emits verbose traces when the deprecated debug flag is enabled", async () => { - // Given + // Given the deprecated debug flag enabled in plugin configuration const api = createMockApi({ serverUrl: "http://localhost:8000", debug: true, }); - // When + // When the plugin evaluates a tool call register(api.api); await runBeforeToolCall(api); - // Then + // Then verbose debug trace messages are emitted const messages = api.info.mock.calls.map(([message]) => String(message)); expect(messages.some((message) => message.includes("before_tool_call entered"))).toBe(true); expect(messages.some((message) => message.includes("phase=evaluate"))).toBe(true); }); it("blocks the tool call before evaluation when fail-closed sync fails", async () => { - // Given + // Given fail-closed mode and a step-resolution failure during sync const api = createMockApi({ serverUrl: "http://localhost:8000", failClosed: true, @@ -242,11 +242,11 @@ describe("agent-control plugin logging and blocking", () => { resolveStepsForContextMock.mockRejectedValueOnce(new Error("resolver exploded")); - // When + // When the plugin attempts to evaluate a tool call register(api.api); const result = await runBeforeToolCall(api); - // Then + // Then the tool call is blocked before evaluation and failure warnings are logged expect(result).toEqual({ block: true, blockReason: USER_BLOCK_MESSAGE, @@ -262,18 +262,18 @@ describe("agent-control plugin logging and blocking", () => { }); it("uses the base agent name when a fixed configured agent ID is present", async () => { - // Given + // Given a fixed configured agent ID and a base agent name const api = createMockApi({ serverUrl: "http://localhost:8000", agentId: VALID_AGENT_ID, agentName: "base-agent", }); - // When + // When a source agent evaluates a tool call register(api.api); await runBeforeToolCall(api, {}, { agentId: "worker-1" }); - // Then + // Then Agent Control receives the base agent name without a source suffix expect(clientMocks.agentsInit).toHaveBeenCalledWith( expect.objectContaining({ agent: expect.objectContaining({ @@ -287,17 +287,17 @@ describe("agent-control plugin logging and blocking", () => { }); it("appends the source agent ID when no configured agent ID is present", async () => { - // Given + // Given a base agent name without a fixed configured agent ID const api = createMockApi({ serverUrl: "http://localhost:8000", agentName: "base-agent", }); - // When + // When a source agent evaluates a tool call register(api.api); await runBeforeToolCall(api, {}, { agentId: "worker-1" }); - // Then + // Then Agent Control receives the base agent name with the source suffix appended expect(clientMocks.agentsInit).toHaveBeenCalledWith( expect.objectContaining({ agent: expect.objectContaining({ @@ -308,17 +308,17 @@ describe("agent-control plugin logging and blocking", () => { }); it("reuses warmup work across repeated gateway_start events", async () => { - // Given + // Given a plugin instance that has already started warmup once const api = createMockApi({ serverUrl: "http://localhost:8000", }); - // When + // When gateway_start is fired twice register(api.api); await runGatewayStart(api); await runGatewayStart(api); - // Then + // Then warmup work is reused and step resolution only runs once expect(resolveStepsForContextMock).toHaveBeenCalledTimes(1); expect(resolveStepsForContextMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -328,14 +328,14 @@ describe("agent-control plugin logging and blocking", () => { }); it("deduplicates concurrent syncs for the same source agent", async () => { - // Given + // Given two concurrent tool calls sharing the same source agent and sync promise const api = createMockApi({ serverUrl: "http://localhost:8000", }); const syncDeferred = createDeferred(); clientMocks.agentsInit.mockImplementation(() => syncDeferred.promise); - // When + // When both tool calls are started before the initial sync completes register(api.api); const first = runBeforeToolCall(api); @@ -348,28 +348,28 @@ describe("agent-control plugin logging and blocking", () => { syncDeferred.resolve(undefined); await Promise.all([first, second]); - // Then + // Then only one sync starts and both tool calls eventually evaluate expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); }); it("skips resyncing when the step catalog has not changed", async () => { - // Given + // Given a source agent whose step catalog is unchanged across two tool calls const api = createMockApi({ serverUrl: "http://localhost:8000", }); - // When + // When the plugin evaluates two tool calls back to back register(api.api); await runBeforeToolCall(api); await runBeforeToolCall(api); - // Then + // Then the agent is only synced once while both evaluations still run expect(clientMocks.agentsInit).toHaveBeenCalledTimes(1); expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); }); it("deduplicates deny controls in the block reason", async () => { - // Given + // Given an unsafe evaluation response with duplicate deny controls const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -384,18 +384,18 @@ describe("agent-control plugin logging and blocking", () => { errors: null, }); - // When + // When the tool call is evaluated and blocked register(api.api); await runBeforeToolCall(api); - // Then + // Then the logged block reason lists each control name only once const message = String(api.warn.mock.calls[0]?.[0]); expect(message).toContain("alpha, beta"); expect(message).not.toContain("alpha, alpha"); }); it("logs the generic block reason when no policy details are returned", async () => { - // Given + // Given an unsafe evaluation response with no policy reason or deny controls const api = createMockApi({ serverUrl: "http://localhost:8000", }); @@ -407,11 +407,11 @@ describe("agent-control plugin logging and blocking", () => { errors: null, }); - // When + // When the tool call is evaluated and blocked register(api.api); await runBeforeToolCall(api); - // Then + // Then the generic policy block reason is logged expect(api.warn).toHaveBeenCalledWith( expect.stringContaining("reason=[agent-control] blocked by policy evaluation"), ); diff --git a/test/logging.test.ts b/test/logging.test.ts index f569d44..8d5362e 100644 --- a/test/logging.test.ts +++ b/test/logging.test.ts @@ -3,97 +3,97 @@ import { createPluginLogger, resolveLogLevel } from "../src/logging.ts"; describe("resolveLogLevel", () => { it("defaults to warn", () => { - // Given + // Given no logging configuration is provided const config = {}; - // When + // When the effective log level is resolved const level = resolveLogLevel(config); - // Then + // Then warn mode is selected by default expect(level).toBe("warn"); }); it("uses an explicit configured level", () => { - // Given + // Given explicit info and debug log level configurations const infoConfig = { logLevel: "info" } as const; const debugConfig = { logLevel: "debug" } as const; - // When + // When each configuration is resolved const infoLevel = resolveLogLevel(infoConfig); const debugLevel = resolveLogLevel(debugConfig); - // Then + // Then the configured levels are preserved expect(infoLevel).toBe("info"); expect(debugLevel).toBe("debug"); }); it("prefers logLevel over the deprecated debug flag", () => { - // Given + // Given both a logLevel value and the deprecated debug flag const config = { logLevel: "warn", debug: true } as const; - // When + // When the effective log level is resolved const level = resolveLogLevel(config); - // Then + // Then the explicit logLevel takes precedence expect(level).toBe("warn"); }); it("falls back to debug for deprecated compatibility", () => { - // Given + // Given an invalid logLevel alongside debug compatibility mode const config = { logLevel: "verbose" as never, debug: true }; - // When + // When the effective log level is resolved const level = resolveLogLevel(config); - // Then + // Then debug mode is used as the compatibility fallback expect(level).toBe("debug"); }); }); describe("createPluginLogger", () => { it("suppresses info and debug output in warn mode", () => { - // Given + // Given a logger configured for warn-only output const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "warn"); - // When + // When info, debug, warn, and block messages are emitted logger.info("info"); logger.debug("debug"); logger.warn("warn"); logger.block("block"); - // Then + // Then only warning-class messages are forwarded expect(info).not.toHaveBeenCalled(); expect(warn.mock.calls).toEqual([["warn"], ["block"]]); }); it("emits info but not debug output in info mode", () => { - // Given + // Given a logger configured for info-level output const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "info"); - // When + // When info and debug messages are emitted logger.info("info"); logger.debug("debug"); - // Then + // Then only the info message is forwarded expect(info.mock.calls).toEqual([["info"]]); expect(warn).not.toHaveBeenCalled(); }); it("emits info and debug output in debug mode", () => { - // Given + // Given a logger configured for debug-level output const info = vi.fn(); const warn = vi.fn(); const logger = createPluginLogger({ info, warn }, "debug"); - // When + // When info and debug messages are emitted logger.info("info"); logger.debug("debug"); - // Then + // Then both messages are forwarded through the info channel expect(info.mock.calls).toEqual([["info"], ["debug"]]); expect(warn).not.toHaveBeenCalled(); }); diff --git a/test/openclaw-runtime.test.ts b/test/openclaw-runtime.test.ts index e362739..0179d1e 100644 --- a/test/openclaw-runtime.test.ts +++ b/test/openclaw-runtime.test.ts @@ -31,7 +31,7 @@ afterEach(() => { describe("openclaw runtime helpers", () => { it("reads the package name and version from package.json", async () => { - // Given + // Given a package.json file with explicit name and version fields const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-pkg-")); const packageJsonPath = path.join(tempDir, "package.json"); fs.writeFileSync( @@ -42,30 +42,30 @@ describe("openclaw runtime helpers", () => { const runtime = await loadRuntimeModule(); - // When + // When package metadata is read from that file const name = runtime.readPackageName(packageJsonPath); const version = runtime.readPackageVersion(packageJsonPath); - // Then + // Then the declared package name and version are returned expect(name).toBe("openclaw"); expect(version).toBe("1.2.3"); }); it("normalizes relative import paths with a leading dot", async () => { - // Given + // Given a runtime helper and source and target files in sibling and same directories const runtime = await loadRuntimeModule(); - // When + // When relative import paths are normalized const siblingImport = runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/c/tool.ts"); const sameDirImport = runtime.normalizeRelativeImportPath("/tmp/a/b", "/tmp/a/b/tool.ts"); - // Then + // Then each result uses a relative posix path with a leading dot expect(siblingImport).toBe("../c/tool.ts"); expect(sameDirImport).toBe("./tool.ts"); }); it("finds the OpenClaw root from cwd when package resolution is unavailable", async () => { - // Given + // Given package resolution failure and a cwd nested under an OpenClaw checkout const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-root-")); fs.writeFileSync( path.join(openClawRoot, "package.json"), @@ -80,15 +80,15 @@ describe("openclaw runtime helpers", () => { packageResolve: new Error("not found"), }); - // When + // When the OpenClaw root directory is resolved const resolvedRoot = runtime.getResolvedOpenClawRootDir(); - // Then + // Then the checkout root is discovered by walking up from cwd expect(resolvedRoot).toBe(fs.realpathSync(openClawRoot)); }); it("throws a helpful error when no OpenClaw root can be found", async () => { - // Given + // Given package resolution failure and a cwd outside any OpenClaw checkout const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-")); process.chdir(tempDir); @@ -96,60 +96,60 @@ describe("openclaw runtime helpers", () => { packageResolve: new Error("not found"), }); - // When + // When the OpenClaw root directory is resolved const resolveRoot = () => runtime.getResolvedOpenClawRootDir(); - // Then + // Then a helpful root-resolution error is thrown expect(resolveRoot).toThrow( "agent-control: unable to resolve openclaw package root for internal tool schema access", ); }); it("returns the first importable JavaScript candidate", async () => { - // Given + // Given candidate module paths where only the second JavaScript file exists const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-js-")); fs.mkdirSync(path.join(openClawRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(openClawRoot, "dist", "candidate.mjs"), "export const value = 123;\n", "utf8"); const runtime = await loadRuntimeModule(); - // When + // When OpenClaw internals are imported through the JavaScript candidate list const imported = await runtime.tryImportOpenClawInternalModule(openClawRoot, [ "dist/missing.mjs", "dist/candidate.mjs", ]); - // Then + // Then the first importable JavaScript module is returned expect(imported).toMatchObject({ value: 123 }); }); it("loads a TypeScript candidate through jiti", async () => { - // Given + // Given a TypeScript candidate file under the OpenClaw source tree const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-ts-")); fs.mkdirSync(path.join(openClawRoot, "src"), { recursive: true }); fs.writeFileSync(path.join(openClawRoot, "src", "candidate.ts"), "export const value = 123;\n", "utf8"); const runtime = await loadRuntimeModule(); - // When + // When the runtime imports OpenClaw internals from TypeScript candidates const imported = await runtime.importOpenClawInternalModule(openClawRoot, ["src/candidate.ts"]); - // Then + // Then the TypeScript module is loaded successfully through jiti expect(imported).toMatchObject({ value: 123 }); }); it("names attempted candidates when no module can be imported", async () => { - // Given + // Given an OpenClaw root with no importable internal module candidates const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-import-missing-")); const runtime = await loadRuntimeModule(); - // When + // When the runtime attempts to import from the candidate list const importPromise = runtime.importOpenClawInternalModule(openClawRoot, [ "src/missing.ts", "dist/missing.js", ]); - // Then + // Then the thrown error names the attempted candidate paths await expect( importPromise, ).rejects.toThrow( diff --git a/test/session-context.test.ts b/test/session-context.test.ts index 3dde077..c258408 100644 --- a/test/session-context.test.ts +++ b/test/session-context.test.ts @@ -40,7 +40,7 @@ beforeEach(() => { describe("buildEvaluationContext", () => { it("derives channel details from the session key when store metadata is unknown", async () => { - // Given + // Given a request with a Discord channel session key and no session-store identity const request = { api: createApi(), sourceAgentId: "worker-1", @@ -62,10 +62,10 @@ describe("buildEvaluationContext", () => { failClosed: false, }; - // When + // When the evaluation context is built const context = await buildEvaluationContext(request); - // Then + // Then channel metadata is derived directly from the session key expect(context).toMatchObject({ openclawAgentId: "worker-1", channelType: "channel", @@ -81,7 +81,7 @@ describe("buildEvaluationContext", () => { }); it("prefers session-store provider and type while retaining the key scope", async () => { - // Given + // Given direct-message identity from the session store and a group-scoped session key resolveSessionIdentityMock.mockResolvedValueOnce({ provider: "slack", type: "direct", @@ -117,10 +117,10 @@ describe("buildEvaluationContext", () => { pluginVersion: "test-version", }; - // When + // When the evaluation context is built const context = await buildEvaluationContext(request); - // Then + // Then provider and channel type come from the store while scope stays from the key expect(context).toMatchObject({ runId: "ctx-run", toolCallId: "ctx-call", @@ -150,7 +150,7 @@ describe("buildEvaluationContext", () => { }); it("falls back to unknown channel information for an unparseable session key", async () => { - // Given + // Given a request whose session key does not match the expected agent format const request = { api: createApi(), sourceAgentId: "worker-1", @@ -169,10 +169,10 @@ describe("buildEvaluationContext", () => { failClosed: false, }; - // When + // When the evaluation context is built const context = await buildEvaluationContext(request); - // Then + // Then channel-related fields fall back to unknown values expect(context).toMatchObject({ channelType: "unknown", channelName: null, diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 1248f2c..6814241 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -50,13 +50,13 @@ afterEach(() => { describe("resolveSessionIdentity", () => { it("returns an unknown identity when no session key is provided", async () => { - // Given + // Given the session-store resolver with no session key input const { resolveSessionIdentity } = await loadSessionStoreModule(); - // When + // When session identity is resolved const identityPromise = resolveSessionIdentity(undefined); - // Then + // Then an unknown identity object is returned await expect(identityPromise).resolves.toEqual({ provider: null, type: "unknown", @@ -71,7 +71,7 @@ describe("resolveSessionIdentity", () => { }); it("maps direct-message metadata from the session store", async () => { - // Given + // Given a session-store entry for a direct-message conversation const { resolveSessionIdentity } = await loadSessionStoreModule({ initialStore: { "agent:worker-1:slack:direct:alice": { @@ -88,10 +88,10 @@ describe("resolveSessionIdentity", () => { }, }); - // When + // When identity is resolved for that direct-message session key const identityPromise = resolveSessionIdentity("agent:worker-1:slack:direct:alice"); - // Then + // Then the direct-message metadata is mapped into the returned identity await expect(identityPromise).resolves.toEqual({ provider: "slack", type: "direct", @@ -106,7 +106,7 @@ describe("resolveSessionIdentity", () => { }); it("reuses base session metadata for thread-specific keys", async () => { - // Given + // Given only a base channel session entry and a thread-specific lookup key const { resolveSessionIdentity } = await loadSessionStoreModule({ initialStore: { "agent:worker-1:slack:channel:eng": { @@ -120,10 +120,10 @@ describe("resolveSessionIdentity", () => { }, }); - // When + // When identity is resolved for the thread-specific key const identityPromise = resolveSessionIdentity("agent:worker-1:slack:channel:eng:thread:123"); - // Then + // Then the base session metadata is reused for the thread await expect(identityPromise).resolves.toMatchObject({ provider: "slack", type: "channel", @@ -134,7 +134,7 @@ describe("resolveSessionIdentity", () => { }); it("reuses the cached identity before the TTL expires", async () => { - // Given + // Given cached session metadata and a TTL window that has not expired vi.useFakeTimers(); const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ initialStore: { @@ -148,7 +148,7 @@ describe("resolveSessionIdentity", () => { }, }); - // When + // When the same session is resolved twice after the backing store changes const first = await resolveSessionIdentity("agent:worker-1:slack:direct:alice"); mocks.setStore({ "agent:worker-1:slack:direct:alice": { @@ -161,14 +161,14 @@ describe("resolveSessionIdentity", () => { }); const second = await resolveSessionIdentity("agent:worker-1:slack:direct:alice"); - // Then + // Then the cached identity is reused and the store is only loaded once expect(first.label).toBe("Alice"); expect(second.label).toBe("Alice"); expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1); }); it("refreshes the identity after the TTL expires", async () => { - // Given + // Given cached session metadata and a store update after the TTL window vi.useFakeTimers(); const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ initialStore: { @@ -182,7 +182,7 @@ describe("resolveSessionIdentity", () => { }, }); - // When + // When the session is resolved again after advancing past the TTL await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ label: "Alice", }); @@ -198,7 +198,7 @@ describe("resolveSessionIdentity", () => { }); vi.advanceTimersByTime(2_001); - // Then + // Then the refreshed identity is returned and the store is reloaded await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ label: "Bob", }); @@ -206,15 +206,15 @@ describe("resolveSessionIdentity", () => { }); it("returns an unknown identity when session-store internals cannot be loaded", async () => { - // Given + // Given a runtime fixture where OpenClaw session-store internals fail to load const { resolveSessionIdentity } = await loadSessionStoreModule({ throws: true, }); - // When + // When identity is resolved for any session key const identityPromise = resolveSessionIdentity("agent:worker-1:slack:direct:alice"); - // Then + // Then the resolver falls back to an unknown identity await expect(identityPromise).resolves.toEqual({ provider: null, type: "unknown", diff --git a/test/shared.test.ts b/test/shared.test.ts index 36f2934..a4023b4 100644 --- a/test/shared.test.ts +++ b/test/shared.test.ts @@ -10,40 +10,40 @@ import { describe("shared utilities", () => { it("returns undefined for a blank string", () => { - // Given + // Given a string that only contains whitespace const value = " "; - // When + // When the string is normalized const normalized = asString(value); - // Then + // Then the helper returns undefined expect(normalized).toBeUndefined(); }); it("floors a positive floating-point number", () => { - // Given + // Given a positive floating-point number const value = 42.9; - // When + // When the number is normalized as a positive integer const normalized = asPositiveInt(value); - // Then + // Then the fractional portion is discarded expect(normalized).toBe(42); }); it("returns undefined for a non-record JSON value", () => { - // Given + // Given a JSON value that is an array instead of an object record const value = ["not", "a", "record"]; - // When + // When the value is coerced to a JSON record const record = toJsonRecord(value); - // Then + // Then no record is returned expect(record).toBeUndefined(); }); it("forces plugins off while preserving sibling config", () => { - // Given + // Given plugin config with plugins enabled and sibling settings present const config = { mode: "test", plugins: { @@ -52,10 +52,10 @@ describe("shared utilities", () => { }, }; - // When + // When the tool catalog config is sanitized const sanitized = sanitizeToolCatalogConfig(config); - // Then + // Then plugins are forced off and unrelated settings are preserved expect(sanitized).toEqual({ mode: "test", plugins: { @@ -66,26 +66,26 @@ describe("shared utilities", () => { }); it("returns a stable placeholder for unserializable arguments", () => { - // Given + // Given a circular argument payload that cannot be JSON serialized const circular: { self?: unknown } = {}; circular.self = circular; - // When + // When the payload is formatted for logging const formatted = formatToolArgsForLog(circular); - // Then + // Then a stable placeholder string is returned expect(formatted).toBe("[unserializable]"); }); it("produces the same digest for identical steps", () => { - // Given + // Given the same step list hashed twice const steps = [{ type: "tool" as const, name: "shell" }]; - // When + // When digests are computed for both hashes const first = hashSteps(steps); const second = hashSteps(steps); - // Then + // Then both digests are identical expect(first).toBe(second); }); }); diff --git a/test/tool-catalog.test.ts b/test/tool-catalog.test.ts index 50e7755..791aa6b 100644 --- a/test/tool-catalog.test.ts +++ b/test/tool-catalog.test.ts @@ -83,7 +83,7 @@ afterEach(() => { describe("resolveStepsForContext", () => { it("deduplicates definitions and disables plugins in synced config", async () => { - // Given + // Given duplicate, invalid, and blank tool definitions from OpenClaw internals const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); const toToolDefinitions = vi.fn(() => [ { @@ -116,7 +116,7 @@ describe("resolveStepsForContext", () => { }); const logger = createLogger(); - // When + // When steps are resolved for the source agent and session context const steps = await resolveStepsForContext({ api: createApi({ plugins: { @@ -132,7 +132,7 @@ describe("resolveStepsForContext", () => { runId: "run-1", }); - // Then + // Then the last valid definition wins and synced config disables plugins expect(steps).toEqual([ { type: "tool", @@ -171,7 +171,7 @@ describe("resolveStepsForContext", () => { }); it("falls back to source modules when dist internals are unavailable", async () => { - // Given + // Given a fixture where dist internals are missing but source modules are available const openClawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tool-catalog-source-")); const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); const toToolDefinitions = vi.fn(() => [ @@ -191,14 +191,14 @@ describe("resolveStepsForContext", () => { sourceAdapterModule: { toToolDefinitions }, }); - // When + // When steps are resolved for the source agent const steps = await resolveStepsForContext({ api: createApi({}), logger: createLogger(), sourceAgentId: "worker-1", }); - // Then + // Then the source-module fallback is used to build tool steps expect(steps).toEqual([ { type: "tool", From 2989be092f98d564f5bb076a7f6ff388cbcac473 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 20 Mar 2026 12:29:22 -0700 Subject: [PATCH 9/9] Fix session-context test typecheck --- test/session-context.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/session-context.test.ts b/test/session-context.test.ts index c258408..7bf485e 100644 --- a/test/session-context.test.ts +++ b/test/session-context.test.ts @@ -100,7 +100,7 @@ describe("buildEvaluationContext", () => { state: { sourceAgentId: "worker-1", agentName: "base-agent:worker-1", - steps: [{ type: "tool", name: "shell" }], + steps: [{ type: "tool" as const, name: "shell" }], stepsHash: "hash-1", lastSyncedStepsHash: "hash-0", syncPromise: null,