From 9497450511d2996dae6632305ee2111a93377657 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 09:05:07 -0700 Subject: [PATCH] refactor: eliminate dead-export baseline (#108376) Burn the grandfathered unused-export baseline to zero and enforce a hard-zero Knip gate. --- config/knip.config.ts | 4 + docs/ci.md | 2 +- package.json | 1 - scripts/check-deadcode-exports.d.mts | 35 +--- scripts/check-deadcode-exports.mjs | 124 ++----------- scripts/deadcode-exports.baseline.mjs | 103 ----------- .../compaction-safeguard.test-support.ts | 46 +++++ .../agent-hooks/compaction-safeguard.test.ts | 23 ++- .../agent-hooks/compaction-safeguard.ts | 7 +- ...s.before-tool-call.integration.e2e.test.ts | 12 +- src/agents/agent-tools.before-tool-call.ts | 15 -- src/agents/agent-tools.schema.test.ts | 10 +- src/agents/apply-patch.test-support.ts | 37 ++++ src/agents/apply-patch.test.ts | 3 +- src/agents/apply-patch.ts | 11 +- src/agents/cli-runner.reliability.test.ts | 7 +- src/agents/cli-runner.spawn.test.ts | 7 +- src/agents/code-mode-headless.test.ts | 3 +- src/agents/code-mode.test-support.ts | 78 +++++++++ src/agents/code-mode.test.ts | 2 +- src/agents/code-mode.ts | 8 +- .../attempt-execution.helpers.test-support.ts | 37 ++++ .../command/attempt-execution.helpers.ts | 10 +- src/agents/command/attempt-execution.test.ts | 6 +- src/agents/compaction-partial-summary.test.ts | 4 +- .../compaction.identifier-policy.test.ts | 2 +- ...compaction.identifier-preservation.test.ts | 4 +- .../compaction.summarize-fallback.test.ts | 2 +- src/agents/compaction.test-support.ts | 47 +++++ .../compaction.tool-result-details.test.ts | 5 +- src/agents/compaction.ts | 11 +- src/agents/config.ts | 24 --- ...-agent-runner-extraparams-moonshot.test.ts | 2 +- ...gent-runner-extraparams-openrouter.test.ts | 6 +- .../embedded-agent-runner-extraparams.test.ts | 2 +- ...tra-params.cache-retention-default.test.ts | 3 +- .../extra-params.google.test.ts | 3 +- .../extra-params.provider-runtime.test.ts | 3 +- .../extra-params.sampling.test.ts | 2 +- .../extra-params.test-support.ts | 36 +++- .../embedded-agent-runner/extra-params.ts | 6 +- .../extra-params.zai-tool-stream.test.ts | 6 +- .../run.lane-timeout.test.ts | 30 ++-- src/agents/embedded-agent-runner/run.ts | 10 -- .../runs.test-support.ts | 17 ++ src/agents/embedded-agent-runner/runs.test.ts | 10 +- src/agents/embedded-agent-runner/runs.ts | 7 +- .../session-manager-cache.test-support.ts | 32 ++++ .../session-manager-cache.test.ts | 2 +- .../session-manager-cache.ts | 7 +- ...ubscribe.handlers.messages.test-support.ts | 63 +++++++ ...-agent-subscribe.handlers.messages.test.ts | 6 +- ...edded-agent-subscribe.handlers.messages.ts | 16 +- ...ded-agent-subscribe.handlers.tools.test.ts | 7 +- ...bedded-agent-subscribe.tools.media.test.ts | 2 +- ...dded-agent-subscribe.tools.test-support.ts | 27 +++ src/agents/embedded-agent-subscribe.tools.ts | 8 +- .../harness/lifecycle-hook-helpers.test.ts | 90 +--------- src/agents/harness/lifecycle-hook-helpers.ts | 10 -- .../harness/tool-surface-bridge.test.ts | 2 +- src/agents/model-fallback.probe.test.ts | 13 +- src/agents/model-fallback.test-support.ts | 47 +++++ src/agents/model-fallback.test.ts | 10 +- src/agents/model-fallback.ts | 15 +- src/agents/modes/interactive/theme/theme.ts | 163 ------------------ .../openai-completions-compat.test-support.ts | 36 ++++ src/agents/openai-completions-compat.test.ts | 6 +- src/agents/openai-completions-compat.ts | 8 +- src/agents/openclaw-tools.sessions.test.ts | 10 +- .../openclaw-tools.subagents.test-harness.ts | 2 +- src/agents/session-suspension.test-support.ts | 30 ++++ src/agents/session-suspension.test.ts | 22 +-- src/agents/session-suspension.ts | 52 +++--- .../sessions/tools/bash.test-support.ts | 14 ++ src/agents/sessions/tools/bash.test.ts | 3 +- src/agents/sessions/tools/bash.ts | 8 +- src/agents/tool-search.stream-errors.test.ts | 32 ++-- src/agents/tool-search.test-support.ts | 47 +++++ src/agents/tool-search.test.ts | 7 +- src/agents/tool-search.ts | 8 +- .../tools/image-generate-tool.test-support.ts | 22 +++ src/agents/tools/image-generate-tool.test.ts | 7 +- src/agents/tools/image-generate-tool.ts | 8 +- ...ol.custom-provider-auth.regression.test.ts | 3 +- .../tools/image-tool.providers.live.test.ts | 3 +- src/agents/tools/image-tool.test-support.ts | 96 +++++++++++ src/agents/tools/image-tool.test.ts | 3 +- src/agents/tools/image-tool.ts | 11 +- .../model-config.helpers.test-support.ts | 23 +++ src/agents/tools/model-config.helpers.test.ts | 2 +- src/agents/tools/model-config.helpers.ts | 8 +- src/agents/tools/sessions-access.test.ts | 2 +- .../tools/sessions-resolution.test-support.ts | 16 ++ src/agents/tools/sessions-resolution.ts | 8 +- .../sessions-send-tool.a2a.test-support.ts | 18 ++ .../tools/sessions-send-tool.a2a.test.ts | 3 +- src/agents/tools/sessions-send-tool.a2a.ts | 8 +- src/agents/tools/sessions-tool.test.ts | 2 +- .../tools/video-generate-tool.test-support.ts | 22 +++ src/agents/tools/video-generate-tool.test.ts | 6 +- src/agents/tools/video-generate-tool.ts | 8 +- src/agents/tools/web-fetch.test-support.ts | 14 ++ src/agents/tools/web-fetch.test.ts | 3 +- src/agents/tools/web-fetch.ts | 8 +- .../transport-params-runtime-contract.test.ts | 2 +- .../utils/tools-manager.test-support.ts | 15 ++ src/agents/utils/tools-manager.test.ts | 6 +- src/agents/utils/tools-manager.ts | 8 +- src/auto-reply/reply/abort.test-support.ts | 34 ++++ src/auto-reply/reply/abort.test.ts | 13 +- src/auto-reply/reply/abort.ts | 6 +- .../reply/acp-reset-target.test-support.ts | 30 ++++ src/auto-reply/reply/acp-reset-target.ts | 7 +- .../agent-runner-context-recovery.test.ts | 69 +++----- .../reply/agent-runner-context-recovery.ts | 2 +- ...nner-memory.preflight-stale-tokens.test.ts | 6 +- .../reply/agent-runner-memory.test-support.ts | 19 ++ .../reply/agent-runner-memory.test.ts | 7 +- src/auto-reply/reply/agent-runner-memory.ts | 8 +- ...agent-runner-session-reset.test-support.ts | 19 ++ .../reply/agent-runner-session-reset.test.ts | 6 +- .../reply/agent-runner-session-reset.ts | 8 +- .../agent-runner.misc.runreplyagent.test.ts | 9 +- .../agent-runner.runreplyagent.e2e.test.ts | 7 +- src/auto-reply/reply/commands-acp.test.ts | 4 +- .../reply/commands-login.test-support.ts | 21 +++ src/auto-reply/reply/commands-login.test.ts | 3 +- src/auto-reply/reply/commands-login.ts | 8 +- .../dispatch-from-config.acp-abort.test.ts | 11 +- ...ispatch-from-config.reply-dispatch.test.ts | 5 +- ...ispatch-from-config.stale-recovery.test.ts | 5 +- .../dispatch-from-config.test-harness.ts | 15 +- .../dispatch-from-config.test-support.ts | 21 +++ src/auto-reply/reply/dispatch-from-config.ts | 9 +- src/auto-reply/reply/followup-runner.test.ts | 4 +- .../reply/get-reply-run.media-only.test.ts | 6 +- src/auto-reply/reply/queue.dedupe.test.ts | 8 +- .../reply/queue.drain-restart.test.ts | 5 +- .../reply/queue/cleanup.test-support.ts | 32 ++++ src/auto-reply/reply/queue/cleanup.test.ts | 3 +- src/auto-reply/reply/queue/cleanup.ts | 7 +- .../reply/queue/enqueue.test-support.ts | 19 ++ src/auto-reply/reply/queue/enqueue.ts | 8 +- .../reply/reply-run-registry.test-support.ts | 21 +++ .../reply/reply-run-registry.test.ts | 2 +- src/auto-reply/reply/reply-run-registry.ts | 7 +- .../reply/reply-turn-admission.test.ts | 2 +- .../reply/session-reset-cleanup.test.ts | 7 +- .../reply/stage-sandbox-media.scp.test.ts | 2 +- .../reply/stage-sandbox-media.test-support.ts | 21 +++ src/auto-reply/reply/stage-sandbox-media.ts | 6 +- .../usage-bar/template.test-support.ts | 19 ++ src/auto-reply/usage-bar/template.test.ts | 3 +- src/auto-reply/usage-bar/template.ts | 8 +- .../usage-bar/template.watcher-proof.test.ts | 3 +- src/cli/channel-options.test-support.ts | 17 ++ src/cli/channel-options.test.ts | 5 +- src/cli/channel-options.ts | 7 +- .../command-secret-gateway.test-support.ts | 34 ++++ src/cli/command-secret-gateway.test.ts | 6 +- src/cli/command-secret-gateway.ts | 7 +- .../gateway-cli/run.supervised-lock.test.ts | 2 +- src/cli/gateway-cli/run.test-support.ts | 46 +++++ src/cli/gateway-cli/run.ts | 6 +- .../plugins-install-command.test-support.ts | 29 ++++ src/cli/plugins-install-command.ts | 8 +- src/cli/plugins-install-config.test.ts | 2 +- src/cli/startup-metadata.test-support.ts | 21 +++ src/cli/startup-metadata.test.ts | 2 +- src/cli/startup-metadata.ts | 7 +- .../update-command-plugins.test-support.ts | 52 ++++++ src/cli/update-cli/update-command-plugins.ts | 17 +- .../update-command-service.test-support.ts | 88 ++++++++++ src/cli/update-cli/update-command-service.ts | 18 +- src/cli/update-cli/update-command.test.ts | 14 +- src/commands/audit.test-support.ts | 37 ++++ src/commands/audit.test.ts | 3 +- src/commands/audit.ts | 7 +- src/commands/backup-shared.ts | 8 +- src/commands/backup.test-support.ts | 45 ++++- src/commands/backup.test.ts | 2 +- .../doctor-auth-oauth-sidecar.test-support.ts | 26 +++ .../doctor-auth-oauth-sidecar.test.ts | 3 +- src/commands/doctor-auth-oauth-sidecar.ts | 8 +- src/commands/doctor-auth.hints.test.ts | 2 +- src/commands/doctor-auth.test-support.ts | 15 ++ src/commands/doctor-auth.ts | 10 +- ...rtbeat-main-session-repair.test-support.ts | 35 ++++ .../doctor-heartbeat-main-session-repair.ts | 13 +- src/commands/doctor-sandbox.test-support.ts | 17 ++ src/commands/doctor-sandbox.test.ts | 2 +- src/commands/doctor-sandbox.ts | 8 +- .../doctor-session-snapshots.test-support.ts | 41 +++++ src/commands/doctor-session-snapshots.test.ts | 6 +- src/commands/doctor-session-snapshots.ts | 13 +- src/commands/doctor-state-integrity.test.ts | 2 +- ...or-whatsapp-responsiveness.test-support.ts | 26 +++ .../doctor-whatsapp-responsiveness.test.ts | 10 +- .../doctor-whatsapp-responsiveness.ts | 13 +- .../codex-native-assets.test-support.ts | 23 +++ .../doctor/shared/codex-native-assets.test.ts | 3 +- .../doctor/shared/codex-native-assets.ts | 8 +- ...codex-route-session-repair.test-support.ts | 19 ++ .../shared/codex-route-session-repair.ts | 8 +- .../shared/codex-route-warnings.test.ts | 7 +- .../doctor/shared/codex-route-warnings.ts | 11 +- .../legacy-oauth-sidecar.test-support.ts | 15 ++ .../shared/legacy-oauth-sidecar.test.ts | 2 +- .../doctor/shared/legacy-oauth-sidecar.ts | 8 +- .../plugin-dependency-cleanup.test-support.ts | 17 ++ .../shared/plugin-dependency-cleanup.test.ts | 19 +- .../shared/plugin-dependency-cleanup.ts | 10 +- .../shared/stale-auth-order.test-support.ts | 22 +++ .../doctor/shared/stale-auth-order.test.ts | 2 +- .../doctor/shared/stale-auth-order.ts | 8 +- ...tale-oauth-profile-shadows.test-support.ts | 34 ++++ .../stale-oauth-profile-shadows.test.ts | 2 +- .../shared/stale-oauth-profile-shadows.ts | 8 +- .../onboard-inference.test-support.ts | 23 +++ src/commands/onboard-inference.test.ts | 2 +- src/commands/onboard-inference.ts | 8 +- ...on-interactive.gateway-health-auth.test.ts | 2 +- .../onboard-non-interactive.gateway.test.ts | 4 +- .../local.test-support.ts | 30 ++++ src/commands/onboard-non-interactive/local.ts | 15 +- src/commands/onboard-skills.test-support.ts | 21 +++ src/commands/onboard-skills.test.ts | 3 +- src/commands/onboard-skills.ts | 7 +- .../onboarding-plugin-install.test-support.ts | 21 +++ .../onboarding-plugin-install.test.ts | 3 +- src/commands/onboarding-plugin-install.ts | 8 +- src/commands/sessions-tail.test-support.ts | 15 ++ src/commands/sessions-tail.test.ts | 3 +- src/commands/sessions-tail.ts | 8 +- src/commands/sessions.test-support.ts | 17 ++ src/commands/sessions.test.ts | 3 +- src/commands/sessions.ts | 7 +- src/commands/status.command.test-support.ts | 23 +++ src/commands/status.command.ts | 8 +- src/commands/status.test.ts | 3 +- .../isolated-agent.delivery-awareness.test.ts | 2 +- .../delivery-dispatch.double-announce.test.ts | 6 +- .../delivery-dispatch.test-support.ts | 20 +++ src/cron/isolated-agent/delivery-dispatch.ts | 12 +- src/cron/schedule.test-support.ts | 30 ++++ src/cron/schedule.test.ts | 6 +- src/cron/schedule.ts | 17 +- src/cron/service.armtimer-tight-loop.test.ts | 3 +- ...ce.issue-66019-unresolved-next-run.test.ts | 2 +- .../service.rearm-timer-when-running.test.ts | 2 +- src/cron/service.removal-postcommit.test.ts | 3 +- .../service.session-reaper-in-finally.test.ts | 4 +- ...s-main-jobs-empty-systemevent-text.test.ts | 2 +- .../service.startup-overflow-clobber.test.ts | 2 +- .../active-run-cancellation.test-support.ts | 15 ++ .../service/active-run-cancellation.test.ts | 2 +- src/cron/service/active-run-cancellation.ts | 8 +- src/cron/service/ops.regression.test.ts | 2 +- src/cron/service/timer.regression.test.ts | 15 +- src/cron/service/timer.test-support.ts | 40 +++++ src/cron/service/timer.test.ts | 2 +- src/cron/service/timer.ts | 11 +- src/cron/session-reaper.test-support.ts | 15 ++ src/cron/session-reaper.test.ts | 3 +- src/cron/session-reaper.ts | 8 +- src/cron/trigger-script.test.ts | 8 +- src/gateway/server-lanes.test.ts | 15 +- src/gateway/talk-realtime-relay.test.ts | 6 +- ...eat-runner.skips-busy-session-lane.test.ts | 6 +- ...stuck-session-recovery.integration.test.ts | 8 +- src/logging/redact-internal-state.ts | 22 +++ src/logging/redact-internal.ts | 19 +- src/logging/redact.test-support.ts | 8 + src/logging/redact.test.ts | 2 +- test/e2e/qa-lab/runtime/script-evidence.ts | 2 +- test/scripts/check-deadcode-exports.test.ts | 75 +++----- 276 files changed, 2970 insertions(+), 1151 deletions(-) delete mode 100644 scripts/deadcode-exports.baseline.mjs create mode 100644 src/agents/agent-hooks/compaction-safeguard.test-support.ts create mode 100644 src/agents/apply-patch.test-support.ts create mode 100644 src/agents/code-mode.test-support.ts create mode 100644 src/agents/command/attempt-execution.helpers.test-support.ts create mode 100644 src/agents/compaction.test-support.ts create mode 100644 src/agents/embedded-agent-runner/runs.test-support.ts create mode 100644 src/agents/embedded-agent-runner/session-manager-cache.test-support.ts create mode 100644 src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts create mode 100644 src/agents/embedded-agent-subscribe.tools.test-support.ts create mode 100644 src/agents/model-fallback.test-support.ts create mode 100644 src/agents/openai-completions-compat.test-support.ts create mode 100644 src/agents/session-suspension.test-support.ts create mode 100644 src/agents/sessions/tools/bash.test-support.ts create mode 100644 src/agents/tool-search.test-support.ts create mode 100644 src/agents/tools/image-generate-tool.test-support.ts create mode 100644 src/agents/tools/image-tool.test-support.ts create mode 100644 src/agents/tools/model-config.helpers.test-support.ts create mode 100644 src/agents/tools/sessions-resolution.test-support.ts create mode 100644 src/agents/tools/sessions-send-tool.a2a.test-support.ts create mode 100644 src/agents/tools/video-generate-tool.test-support.ts create mode 100644 src/agents/tools/web-fetch.test-support.ts create mode 100644 src/agents/utils/tools-manager.test-support.ts create mode 100644 src/auto-reply/reply/abort.test-support.ts create mode 100644 src/auto-reply/reply/acp-reset-target.test-support.ts create mode 100644 src/auto-reply/reply/agent-runner-memory.test-support.ts create mode 100644 src/auto-reply/reply/agent-runner-session-reset.test-support.ts create mode 100644 src/auto-reply/reply/commands-login.test-support.ts create mode 100644 src/auto-reply/reply/dispatch-from-config.test-support.ts create mode 100644 src/auto-reply/reply/queue/cleanup.test-support.ts create mode 100644 src/auto-reply/reply/queue/enqueue.test-support.ts create mode 100644 src/auto-reply/reply/reply-run-registry.test-support.ts create mode 100644 src/auto-reply/reply/stage-sandbox-media.test-support.ts create mode 100644 src/auto-reply/usage-bar/template.test-support.ts create mode 100644 src/cli/channel-options.test-support.ts create mode 100644 src/cli/command-secret-gateway.test-support.ts create mode 100644 src/cli/gateway-cli/run.test-support.ts create mode 100644 src/cli/plugins-install-command.test-support.ts create mode 100644 src/cli/startup-metadata.test-support.ts create mode 100644 src/cli/update-cli/update-command-plugins.test-support.ts create mode 100644 src/cli/update-cli/update-command-service.test-support.ts create mode 100644 src/commands/audit.test-support.ts create mode 100644 src/commands/doctor-auth-oauth-sidecar.test-support.ts create mode 100644 src/commands/doctor-auth.test-support.ts create mode 100644 src/commands/doctor-heartbeat-main-session-repair.test-support.ts create mode 100644 src/commands/doctor-sandbox.test-support.ts create mode 100644 src/commands/doctor-session-snapshots.test-support.ts create mode 100644 src/commands/doctor-whatsapp-responsiveness.test-support.ts create mode 100644 src/commands/doctor/shared/codex-native-assets.test-support.ts create mode 100644 src/commands/doctor/shared/codex-route-session-repair.test-support.ts create mode 100644 src/commands/doctor/shared/legacy-oauth-sidecar.test-support.ts create mode 100644 src/commands/doctor/shared/plugin-dependency-cleanup.test-support.ts create mode 100644 src/commands/doctor/shared/stale-auth-order.test-support.ts create mode 100644 src/commands/doctor/shared/stale-oauth-profile-shadows.test-support.ts create mode 100644 src/commands/onboard-inference.test-support.ts create mode 100644 src/commands/onboard-non-interactive/local.test-support.ts create mode 100644 src/commands/onboard-skills.test-support.ts create mode 100644 src/commands/onboarding-plugin-install.test-support.ts create mode 100644 src/commands/sessions-tail.test-support.ts create mode 100644 src/commands/sessions.test-support.ts create mode 100644 src/commands/status.command.test-support.ts create mode 100644 src/cron/isolated-agent/delivery-dispatch.test-support.ts create mode 100644 src/cron/schedule.test-support.ts create mode 100644 src/cron/service/active-run-cancellation.test-support.ts create mode 100644 src/cron/service/timer.test-support.ts create mode 100644 src/cron/session-reaper.test-support.ts create mode 100644 src/logging/redact-internal-state.ts create mode 100644 src/logging/redact.test-support.ts diff --git a/config/knip.config.ts b/config/knip.config.ts index 223e779588a..2220ef01bdf 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -21,6 +21,10 @@ const rootEntries = [ "src/agents/model-provider-auth.worker.ts!", // Loaded lazily by the registry; its callbacks form the orphan-recovery runtime contract. "src/agents/subagent-orphan-recovery.ts!", + // Task cancellation loads this control facade by string path to avoid a registry cycle. + "src/tasks/task-registry-control.runtime.ts!", + // Human plugin listing lazily loads its formatter to keep JSON startup lean. + "src/cli/plugins-list-format.ts!", "src/infra/kysely-node-sqlite.ts!", "src/infra/warning-filter.ts!", "src/infra/command-explainer/index.ts!", diff --git a/docs/ci.md b/docs/ci.md index 96d20511d23..fe4e1462326 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -125,7 +125,7 @@ job budget. Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push. -The `check-dependencies` shard runs production Knip dependency, unused-file, and unused-export checks. The unused-file guard fails when a PR adds a new unreviewed unused file or leaves a stale allowlist entry, while preserving intentional dynamic plugin, generated, build, live-test, and package bridge surfaces that Knip cannot resolve statically. The unused-export guard excludes test-support files, then fails on new findings or stale required baseline entries; after deleting dead exports, regenerate the shrink-only baseline with `pnpm deadcode:exports:update`. Historical targets run the export guard when they provide it and retain their older dead-code fallback otherwise. +The `check-dependencies` shard runs production Knip dependency, unused-file, and unused-export checks. The unused-file guard fails when a PR adds a new unreviewed unused file or leaves a stale allowlist entry, while preserving intentional dynamic plugin, generated, build, live-test, and package bridge surfaces that Knip cannot resolve statically. The unused-export guard excludes test-support files and fails on every unused production export; intentional dynamic consumers must be modeled in `config/knip.config.ts`. Historical targets run the export guard when they provide it and retain their older dead-code fallback otherwise. ## ClawSweeper activity forwarding diff --git a/package.json b/package.json index d1c15eeb00d..dcb66437f33 100644 --- a/package.json +++ b/package.json @@ -1591,7 +1591,6 @@ "crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup", "deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints", "deadcode:exports": "node scripts/check-deadcode-exports.mjs", - "deadcode:exports:update": "node scripts/check-deadcode-exports.mjs --update", "deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies", "deadcode:report": "pnpm deadcode:knip; pnpm deadcode:exports", "deadcode:unused-files": "node scripts/check-deadcode-unused-files.mjs", diff --git a/scripts/check-deadcode-exports.d.mts b/scripts/check-deadcode-exports.d.mts index 70dccc22528..3edf6b09da1 100644 --- a/scripts/check-deadcode-exports.d.mts +++ b/scripts/check-deadcode-exports.d.mts @@ -5,38 +5,9 @@ export function parseKnipCompactUnusedExportsResult(output: string): { entries: string[]; sawExportSection: boolean; }; -/** Compares detected unused exports against the checked-in baseline. */ -export function compareUnusedExportsToBaseline( - actualEntries: string[], - baselineEntries: string[], - optionalBaselineEntries?: string[], -): { - actual: string[]; - allowed: string[]; - unexpected: string[]; - stale: string[]; - duplicateAllowedCount: number; - allowlistIsSorted: boolean; -}; -/** Emits the checked-in baseline module used by --update. */ -export function formatUnusedExportBaseline( - requiredEntries: string[], - optionalEntries?: string[], -): string; -/** Checks Knip output against the current baseline. */ -export function checkUnusedExports( - output: string, - baselineEntries?: string[], - optionalBaselineEntries?: string[], -): { +/** Rejects every unused export reported by Knip. */ +export function checkUnusedExports(output: string): { ok: boolean; - comparison: { - actual: string[]; - allowed: string[]; - unexpected: string[]; - stale: string[]; - duplicateAllowedCount: number; - allowlistIsSorted: boolean; - }; + entries: string[]; message: string; }; diff --git a/scripts/check-deadcode-exports.mjs b/scripts/check-deadcode-exports.mjs index 73a3d2850bb..8b51236f721 100644 --- a/scripts/check-deadcode-exports.mjs +++ b/scripts/check-deadcode-exports.mjs @@ -1,17 +1,7 @@ #!/usr/bin/env node -// Enforces a ratcheting baseline for Knip's unused exports. -import { writeFile } from "node:fs/promises"; +// Enforces a hard-zero policy for Knip's unused exports. import { fileURLToPath } from "node:url"; -import { - KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE, - KNIP_UNUSED_EXPORT_BASELINE, -} from "./deadcode-exports.baseline.mjs"; -import { - compareStringListToAllowlist, - isLikelyRepoFilePath, - runKnip, - uniqueSorted, -} from "./deadcode-knip-runner.mjs"; +import { isLikelyRepoFilePath, runKnip, uniqueSorted } from "./deadcode-knip-runner.mjs"; const KNIP_ARGS = [ "--config", @@ -25,10 +15,6 @@ const KNIP_ARGS = [ "--no-config-hints", ]; -const BASELINE_HEADER = `// Pre-existing unused exports awaiting deletion. -// New entries fail CI. After deleting dead code, run \`pnpm deadcode:exports:update\`. -// Do not add entries to avoid fixing new findings.`; - /** Parses compact Knip export sections into one path-and-symbol entry per finding. */ export function parseKnipCompactUnusedExportsResult(output) { const entries = []; @@ -77,90 +63,24 @@ export function parseKnipCompactUnusedExports(output) { return parseKnipCompactUnusedExportsResult(output).entries; } -/** Compares detected unused exports against the checked-in baseline. */ -export function compareUnusedExportsToBaseline( - actualEntries, - baselineEntries, - optionalBaselineEntries = [], -) { - return compareStringListToAllowlist(actualEntries, baselineEntries, optionalBaselineEntries); -} - -function formatUnusedExportComparison(comparison) { - const lines = []; - if (!comparison.allowlistIsSorted) { - lines.push("deadcode unused-export baseline is not sorted."); - } - if (comparison.duplicateAllowedCount > 0) { - lines.push( - `deadcode unused-export baseline contains ${comparison.duplicateAllowedCount} duplicate entr${ - comparison.duplicateAllowedCount === 1 ? "y" : "ies" - }.`, - ); - } - if (comparison.unexpected.length > 0) { - lines.push("Unexpected unused exports:"); - lines.push(...comparison.unexpected.map((entry) => ` ${entry}`)); - } - if (comparison.stale.length > 0) { - lines.push("Stale required baseline entries:"); - lines.push(...comparison.stale.map((entry) => ` ${entry}`)); - } - if (lines.length > 0) { - lines.push("Run `pnpm deadcode:exports:update` after removing dead code."); - } - return lines.join("\n"); -} - -function formatArrayExport(name, entries) { - if (entries.length === 0) { - return `export const ${name} = [];`; - } - return `export const ${name} = [\n${entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n")}\n];`; -} - -/** Emits the checked-in baseline module used by --update. */ -export function formatUnusedExportBaseline( - requiredEntries, - optionalEntries = KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE, -) { - // Optional entries are platform-variant: promoting one into the required - // list would make CI stale-fail on platforms where it is legitimately absent. - const optionalSet = new Set(uniqueSorted(optionalEntries)); - return `${BASELINE_HEADER}\n${formatArrayExport( - "KNIP_UNUSED_EXPORT_BASELINE", - uniqueSorted(requiredEntries).filter((entry) => !optionalSet.has(entry)), - )}\n\n// Platform-variant findings. Allowed when present; never required.\n${formatArrayExport( - "KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE", - uniqueSorted(optionalEntries), - )}\n`; -} - -/** Checks Knip output against the current baseline. */ -export function checkUnusedExports( - output, - baselineEntries = KNIP_UNUSED_EXPORT_BASELINE, - optionalBaselineEntries = KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE, -) { - const actual = parseKnipCompactUnusedExports(output); - const comparison = compareUnusedExportsToBaseline( - actual, - baselineEntries, - optionalBaselineEntries, - ); +/** Rejects every unused export reported by Knip. */ +export function checkUnusedExports(output) { + const entries = parseKnipCompactUnusedExports(output); return { - ok: - comparison.allowlistIsSorted && - comparison.duplicateAllowedCount === 0 && - comparison.unexpected.length === 0 && - comparison.stale.length === 0, - comparison, - message: formatUnusedExportComparison(comparison), + ok: entries.length === 0, + entries, + message: + entries.length === 0 + ? "" + : [ + "Unused exports are not allowed:", + ...entries.map((entry) => ` ${entry}`), + "Delete the exports or model their real production consumers in Knip.", + ].join("\n"), }; } async function main() { - const update = process.argv.slice(2).includes("--update"); const result = await runKnip(KNIP_ARGS, { scanName: "unused-export scan" }); if (result.errorCode || result.status === null) { console.error( @@ -188,25 +108,13 @@ async function main() { return; } - const actual = parsed.entries; - if (update) { - const baselinePath = fileURLToPath(new URL("./deadcode-exports.baseline.mjs", import.meta.url)); - await writeFile( - baselinePath, - formatUnusedExportBaseline(actual, KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE), - "utf8", - ); - console.log(`[deadcode] Updated unused-export baseline with ${actual.length} entries.`); - return; - } - const check = checkUnusedExports(result.output); if (!check.ok) { console.error(check.message); process.exitCode = 1; return; } - console.log(`[deadcode] Knip unused-export baseline matched ${actual.length} entries.`); + console.log("[deadcode] Knip unused-export check passed with 0 entries."); } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs deleted file mode 100644 index 4be12d9a3c0..00000000000 --- a/scripts/deadcode-exports.baseline.mjs +++ /dev/null @@ -1,103 +0,0 @@ -// Pre-existing unused exports awaiting deletion. -// New entries fail CI. After deleting dead code, run `pnpm deadcode:exports:update`. -// Do not add entries to avoid fixing new findings. -export const KNIP_UNUSED_EXPORT_BASELINE = [ - "src/agents/agent-hooks/compaction-safeguard.ts: testing", - "src/agents/agent-tools.before-tool-call.ts: testing", - "src/agents/apply-patch.ts: applyPatch", - "src/agents/code-mode.ts: testing", - "src/agents/command/attempt-execution.helpers.ts: claudeCliSessionTranscriptPath", - "src/agents/command/attempt-execution.helpers.ts: formatClaudeCliFallbackPrelude", - "src/agents/compaction.ts: buildCompactionSummarizationInstructions", - "src/agents/compaction.ts: summarizeWithFallback", - "src/agents/embedded-agent-runner/extra-params.ts: testing", - "src/agents/embedded-agent-runner/run.ts: testing", - "src/agents/embedded-agent-runner/runs.ts: testing", - "src/agents/embedded-agent-runner/session-manager-cache.ts: createSessionManagerCache", - "src/agents/embedded-agent-subscribe.handlers.messages.ts: buildAssistantStreamData", - "src/agents/embedded-agent-subscribe.handlers.messages.ts: recordPendingAssistantReplyDirectives", - "src/agents/embedded-agent-subscribe.handlers.messages.ts: resolveSilentReplyFallbackText", - "src/agents/embedded-agent-subscribe.tools.ts: isToolResultMediaTrusted", - "src/agents/harness/lifecycle-hook-helpers.ts: clearAgentHarnessFinalizeRetryBudget", - "src/agents/model-fallback.ts: testing", - "src/agents/modes/interactive/theme/theme.ts: setTheme", - "src/agents/openai-completions-compat.ts: resolveOpenAICompletionsCompatDefaults", - "src/agents/session-suspension.ts: testing", - "src/agents/sessions/tools/bash.ts: resolveBashTimeoutMs", - "src/agents/tool-search.ts: testing", - "src/agents/tool-search.ts: ToolSearchCatalogSession", - "src/agents/tools/image-generate-tool.ts: resolveImageGenerationModelConfigForTool", - "src/agents/tools/image-tool.ts: resolveImageModelConfigForTool", - "src/agents/tools/image-tool.ts: testing", - "src/agents/tools/model-config.helpers.ts: hasDirectProviderApiKeyAuthForTool", - "src/agents/tools/sessions-resolution.ts: testing", - "src/agents/tools/sessions-send-tool.a2a.ts: testing", - "src/agents/tools/video-generate-tool.ts: resolveVideoGenerationModelConfigForTool", - "src/agents/tools/web-fetch.ts: sanitizeWebFetchUrl", - "src/agents/utils/tools-manager.ts: testing", - "src/auto-reply/reply/abort.ts: testing", - "src/auto-reply/reply/acp-reset-target.ts: testing", - "src/auto-reply/reply/agent-runner-context-recovery.ts: computeContextAwareReserveTokensFloor", - "src/auto-reply/reply/agent-runner-memory.ts: setAgentRunnerMemoryTestDeps", - "src/auto-reply/reply/agent-runner-session-reset.ts: setAgentRunnerSessionResetTestDeps", - "src/auto-reply/reply/commands-login.ts: testing", - "src/auto-reply/reply/dispatch-from-config.ts: testing", - "src/auto-reply/reply/queue/cleanup.ts: testing", - "src/auto-reply/reply/queue/enqueue.ts: resetRecentQueuedMessageIdDedupe", - "src/auto-reply/reply/reply-run-registry.ts: testing", - "src/auto-reply/reply/stage-sandbox-media.ts: testing", - "src/auto-reply/usage-bar/template.ts: clearUsageBarTemplateCacheForTest", - "src/cli/channel-options.ts: testing", - "src/cli/command-secret-gateway.ts: testing", - "src/cli/gateway-cli/run.ts: testing", - "src/cli/plugins-install-command.ts: loadConfigForInstall", - "src/cli/plugins-list-format.ts: formatPluginLine", - "src/cli/startup-metadata.ts: testing", - "src/cli/update-cli/update-command-plugins.ts: buildInvalidConfigPostCoreUpdateResult", - "src/cli/update-cli/update-command-plugins.ts: collectMissingPluginInstallPayloads", - "src/cli/update-cli/update-command-plugins.ts: resolvePostSyncPluginUpdateSkipIds", - "src/cli/update-cli/update-command-service.ts: formatPostUpdateGatewayRecoveryInstructions", - "src/cli/update-cli/update-command-service.ts: recoverInstalledLaunchAgentAfterUpdate", - "src/cli/update-cli/update-command-service.ts: recoverLaunchAgentAndRecheckGatewayHealth", - "src/cli/update-cli/update-command-service.ts: shouldUseLegacyProcessRestartAfterUpdate", - "src/commands/audit.ts: testApi", - "src/commands/backup-shared.ts: resolveBackupPlanFromPaths", - "src/commands/doctor-auth-oauth-sidecar.ts: testing", - "src/commands/doctor-auth.ts: legacyCodexProviderOverrideToHealthFinding", - "src/commands/doctor-heartbeat-main-session-repair.ts: moveHeartbeatMainSessionEntry", - "src/commands/doctor-heartbeat-main-session-repair.ts: resolveHeartbeatMainSessionRepairCandidate", - "src/commands/doctor-sandbox.ts: resolveSandboxScript", - "src/commands/doctor-session-snapshots.ts: resolveSessionSnapshotBundledSkillsDir", - "src/commands/doctor-session-snapshots.ts: scanSessionStoreForStaleRuntimeSnapshotPaths", - "src/commands/doctor-whatsapp-responsiveness.ts: listLocalTuiProcesses", - "src/commands/doctor-whatsapp-responsiveness.ts: terminateLocalTuiProcesses", - "src/commands/doctor/shared/codex-native-assets.ts: scanCodexNativeAssets", - "src/commands/doctor/shared/codex-route-warnings.ts: repairCodexSessionStoreRoutes", - "src/commands/doctor/shared/legacy-oauth-sidecar.ts: legacyOAuthSidecarInternalTestUtils", - "src/commands/doctor/shared/plugin-dependency-cleanup.ts: testing", - "src/commands/doctor/shared/stale-auth-order.ts: repairStaleConfiguredAuthOrders", - "src/commands/doctor/shared/stale-oauth-profile-shadows.ts: testing", - "src/commands/onboard-inference.ts: detectNativeCodexAppServer", - "src/commands/onboard-non-interactive/local.ts: resolveGatewayHealthProbeToken", - "src/commands/onboard-non-interactive/local.ts: resolveInstallDaemonGatewayHealthTiming", - "src/commands/onboard-skills.ts: testing", - "src/commands/onboarding-plugin-install.ts: testing", - "src/commands/sessions-tail.ts: setSessionsTailFollowIntervalMsForTests", - "src/commands/sessions.ts: testing", - "src/commands/status.command.ts: resolvePairingRecoveryContext", - "src/cron/isolated-agent/delivery-dispatch.ts: getCompletedDirectCronDeliveriesCountForTests", - "src/cron/isolated-agent/delivery-dispatch.ts: resetCompletedDirectCronDeliveriesForTests", - "src/cron/schedule.ts: clearCronScheduleCacheForTest", - "src/cron/schedule.ts: getCronScheduleCacheMaxForTest", - "src/cron/schedule.ts: getCronScheduleCacheSizeForTest", - "src/cron/schedule.ts: hasCronInCacheForTest", - "src/cron/service/active-run-cancellation.ts: cancelActiveCronTaskRun", - "src/cron/service/active-run-cancellation.ts: resetActiveCronTaskRunsForTests", - "src/cron/service/timer.ts: executeJobCore", - "src/cron/service/timer.ts: onTimer", - "src/cron/session-reaper.ts: resetReaperThrottle", - "src/logging/redact-internal.ts: withFullContextToolPayloadRedaction", -]; - -// Platform-variant findings. Allowed when present; never required. -export const KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE = []; diff --git a/src/agents/agent-hooks/compaction-safeguard.test-support.ts b/src/agents/agent-hooks/compaction-safeguard.test-support.ts new file mode 100644 index 00000000000..b9644ce44b5 --- /dev/null +++ b/src/agents/agent-hooks/compaction-safeguard.test-support.ts @@ -0,0 +1,46 @@ +import type { summarizeInStages } from "../compaction.js"; +import "./compaction-safeguard.js"; + +type CompactionSafeguardTestApi = { + setSummarizeInStagesForTest(next?: typeof summarizeInStages): void; + collectToolFailures: CallableFunction; + formatToolFailuresSection: CallableFunction; + splitPreservedRecentTurns: CallableFunction; + formatPreservedTurnsSection: CallableFunction; + formatSplitTurnContextSection: CallableFunction; + buildCompactionStructureInstructions: CallableFunction; + buildStructuredFallbackSummary: CallableFunction; + prependPreviousSummaryForRedistill: CallableFunction; + appendSummarySection: CallableFunction; + resolveRecentTurnsPreserve: CallableFunction; + resolveQualityGuardMaxRetries: CallableFunction; + extractOpaqueIdentifiers: CallableFunction; + auditSummaryQuality: CallableFunction; + capCompactionSummary: CallableFunction; + capCompactionSummaryPreservingSuffix: CallableFunction; + formatFileOperations: CallableFunction; + computeAdaptiveChunkRatio: CallableFunction; + isOversizedForSummary: CallableFunction; + readWorkspaceContextForSummary: CallableFunction; + hasMeaningfulConversationContent: CallableFunction; + isRealConversationMessage: CallableFunction; + BASE_CHUNK_RATIO: number; + MIN_CHUNK_RATIO: number; + SAFETY_MARGIN: number; + MAX_COMPACTION_SUMMARY_CHARS: number; + MAX_FILE_OPS_SECTION_CHARS: number; + MAX_FILE_OPS_LIST_CHARS: number; + SUMMARY_TRUNCATED_MARKER: string; +}; + +function getTestApi(): CompactionSafeguardTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.compactionSafeguardTestApi") + ]; + if (!api) { + throw new Error("compaction safeguard test API is unavailable"); + } + return api as CompactionSafeguardTestApi; +} + +export const testing = getTestApi(); diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index 9ca81105795..f72972fc460 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -21,7 +21,8 @@ import { setCompactionSafeguardCancelReason, setCompactionSafeguardRuntime, } from "./compaction-safeguard-runtime.js"; -import compactionSafeguardExtension, { testing } from "./compaction-safeguard.js"; +import compactionSafeguardExtension from "./compaction-safeguard.js"; +import { testing } from "./compaction-safeguard.test-support.js"; vi.mock("../compaction.js", async () => { const actual = await vi.importActual("../compaction.js"); @@ -310,7 +311,7 @@ describe("compaction-safeguard tool failures", () => { ]; const failures = collectToolFailures(messages); - expect(failures.map((failure) => failure.toolCallId)).toEqual([ + expect(failures.map((failure: { toolCallId: string }) => failure.toolCallId)).toEqual([ "call-spawn-error", "call-spawn-forbidden", ]); @@ -358,7 +359,7 @@ describe("compaction-safeguard tool failures", () => { ]; const failures = collectToolFailures(messages); - expect(failures.map((failure) => failure.toolCallId)).toEqual([ + expect(failures.map((failure: { toolCallId: string }) => failure.toolCallId)).toEqual([ "call-exec-failed", "call-other-lookalike", ]); @@ -848,7 +849,7 @@ describe("compaction-safeguard recent-turn preservation", () => { recentTurnsPreserve: 1, }); - expect(split.preservedMessages.map((msg) => msg.role)).toEqual([ + expect(split.preservedMessages.map((msg: AgentMessage) => msg.role)).toEqual([ "user", "assistant", "toolResult", @@ -856,13 +857,14 @@ describe("compaction-safeguard recent-turn preservation", () => { ]); expect( split.preservedMessages.some( - (msg) => msg.role === "user" && (msg as { content?: unknown }).content === "recent ask", + (msg: AgentMessage) => + msg.role === "user" && (msg as { content?: unknown }).content === "recent ask", ), ).toBe(true); const summarizableToolResultIds = split.summarizableMessages - .filter((msg) => msg.role === "toolResult") - .map((msg) => (msg as { toolCallId?: unknown }).toolCallId); + .filter((msg: AgentMessage) => msg.role === "toolResult") + .map((msg: AgentMessage) => (msg as { toolCallId?: unknown }).toolCallId); expect(summarizableToolResultIds).toContain("call_old"); expect(summarizableToolResultIds).not.toContain("call_recent"); }); @@ -1016,7 +1018,7 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(split.preservedMessages).toHaveLength(6); expect( split.preservedMessages.some( - (msg) => + (msg: AgentMessage) => msg.role === "user" && (msg as { content?: unknown }).content === "single user prompt", ), ).toBe(true); @@ -1080,7 +1082,10 @@ describe("compaction-safeguard recent-turn preservation", () => { "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and again a1b2c3d4e5f6", ); expect( - identifiers.reduce((count, id) => count + (id === "A1B2C3D4E5F6" ? 1 : 0), 0), // pragma: allowlist secret + identifiers.reduce( + (count: number, id: string) => count + (id === "A1B2C3D4E5F6" ? 1 : 0), // pragma: allowlist secret + 0, + ), ).toBe(1); }); diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 35970fd05e5..01ad6143aa3 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -1329,7 +1329,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }); } -export const testing = { +const testing = { setSummarizeInStagesForTest(next?: typeof summarizeInStages) { compactionSafeguardDeps.summarizeInStages = next ?? summarizeInStages; }, @@ -1362,4 +1362,9 @@ export const testing = { MAX_FILE_OPS_LIST_CHARS, SUMMARY_TRUNCATED_MARKER, } as const; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.compactionSafeguardTestApi")] = + testing; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/agent-tools.before-tool-call.integration.e2e.test.ts b/src/agents/agent-tools.before-tool-call.integration.e2e.test.ts index e12d11d67c9..8a574c0d312 100644 --- a/src/agents/agent-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/agent-tools.before-tool-call.integration.e2e.test.ts @@ -24,12 +24,16 @@ import type { PluginHookRegistration } from "../plugins/types.js"; import { toClientToolDefinitions, toToolDefinitions } from "./agent-tool-definition-adapter.js"; import { wrapToolWithAbortSignal } from "./agent-tools.abort.js"; import { - testing as beforeToolCallTesting, consumeAdjustedParamsForToolCall, finalizeToolTerminalPresentation, isToolWrappedWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, } from "./agent-tools.before-tool-call.js"; +import { + adjustedParamsByToolCallId, + buildAdjustedParamsKey, + structuredReplaySafeToolCallIds, +} from "./agent-tools.before-tool-call.state.js"; import { normalizeToolParameters } from "./agent-tools.schema.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { markCodeModeControlTool } from "./code-mode-control-tools.js"; @@ -40,6 +44,12 @@ import { setToolTerminalPresentation } from "./tool-terminal-presentation.js"; type BeforeToolCallHandlerMock = ReturnType; +const beforeToolCallTesting = { + adjustedParamsByToolCallId, + buildAdjustedParamsKey, + structuredReplaySafeToolCallIds, +}; + function asAgentTool(tool: { description?: string; execute: ReturnType; diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index b3dadad023e..95a1ad8bd72 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -2064,21 +2064,6 @@ function recordPreExecutionBlockedToolCall(toolCallId?: string, runId?: string): } } -/** Test-only access to before_tool_call internals. */ -export const testing = { - BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS, - BEFORE_TOOL_CALL_HOOK_CONTEXT, - BEFORE_TOOL_CALL_SOURCE_TOOL, - BEFORE_TOOL_CALL_WRAPPED, - buildAdjustedParamsKey, - adjustedParamsByToolCallId, - preExecutionBlockedToolCallIds, - structuredReplaySafeToolCallIds, - runBeforeToolCallHook, - mergeParamsWithApprovalOverrides, - isPlainObject, -}; - function toLintErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { return value; diff --git a/src/agents/agent-tools.schema.test.ts b/src/agents/agent-tools.schema.test.ts index 5cbe359ea5e..e43d516023d 100644 --- a/src/agents/agent-tools.schema.test.ts +++ b/src/agents/agent-tools.schema.test.ts @@ -11,11 +11,19 @@ import { Type, type TSchema } from "typebox"; import { describe, expect, it, vi } from "vitest"; import { isToolWrappedWithBeforeToolCallHook, - testing as beforeToolCallTesting, wrapToolWithBeforeToolCallHook, } from "./agent-tools.before-tool-call.js"; import { normalizeToolParameters } from "./agent-tools.schema.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; +import { + BEFORE_TOOL_CALL_HOOK_CONTEXT, + BEFORE_TOOL_CALL_SOURCE_TOOL, +} from "./before-tool-call-metadata.js"; + +const beforeToolCallTesting = { + BEFORE_TOOL_CALL_HOOK_CONTEXT, + BEFORE_TOOL_CALL_SOURCE_TOOL, +}; const TEST_USAGE = { input: 0, diff --git a/src/agents/apply-patch.test-support.ts b/src/agents/apply-patch.test-support.ts new file mode 100644 index 00000000000..d6011685b44 --- /dev/null +++ b/src/agents/apply-patch.test-support.ts @@ -0,0 +1,37 @@ +import type { ApplyPatchSummary } from "./apply-patch.js"; +import "./apply-patch.js"; +import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; + +type ApplyPatchOptions = { + cwd: string; + sandbox?: { root: string; bridge: SandboxFsBridge }; + workspaceOnly?: boolean; + signal?: AbortSignal; +}; + +type ApplyPatchResult = { + summary: ApplyPatchSummary; + text: string; + noOp?: boolean; +}; + +type ApplyPatchTestApi = { + applyPatch(input: string, options: ApplyPatchOptions): Promise; +}; + +function getTestApi(): ApplyPatchTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.applyPatchTestApi") + ]; + if (!api) { + throw new Error("apply patch test API is unavailable"); + } + return api as ApplyPatchTestApi; +} + +export async function applyPatch( + input: string, + options: ApplyPatchOptions, +): Promise { + return await getTestApi().applyPatch(input, options); +} diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index ad13ef945e2..f9fefdb2d84 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -11,7 +11,8 @@ import { createRebindableDirectoryAlias, withRealpathSymlinkRebindRace, } from "../test-utils/symlink-rebind-race.js"; -import { applyPatch, createApplyPatchTool } from "./apply-patch.js"; +import { createApplyPatchTool } from "./apply-patch.js"; +import { applyPatch } from "./apply-patch.test-support.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; async function withTempDir(fn: (dir: string) => Promise) { diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index 12941f50e3a..19144b31738 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -137,10 +137,7 @@ export function createApplyPatchTool( } /** Parse and apply a patch envelope to the configured filesystem target. */ -export async function applyPatch( - input: string, - options: ApplyPatchOptions, -): Promise { +async function applyPatch(input: string, options: ApplyPatchOptions): Promise { const parsed = parsePatchText(input); if (parsed.hunks.length === 0) { throw new Error("No files were modified."); @@ -714,3 +711,9 @@ function parseUpdateFileChunk( return { chunk, consumed: parsedLines + startIndex }; } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.applyPatchTestApi")] = { + applyPatch, + }; +} diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 2afcbd607ad..b8f438e1552 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -7,11 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; -import { - testing as replyRunTesting, - createReplyOperation, - replyRunRegistry, -} from "../auto-reply/reply/reply-run-registry.js"; +import { createReplyOperation, replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { loadTranscriptEvents, upsertSessionEntry } from "../config/sessions/session-accessor.js"; import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 883ce0967ed..b02b1693104 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -4,11 +4,8 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - testing as replyRunTesting, - createReplyOperation, - replyRunRegistry, -} from "../auto-reply/reply/reply-run-registry.js"; +import { createReplyOperation, replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js"; import { markMcpLoopbackToolCallFinished, markMcpLoopbackToolCallStarted, diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts index 7a65b85a1dd..f7332c1b8d3 100644 --- a/src/agents/code-mode-headless.test.ts +++ b/src/agents/code-mode-headless.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../shared/deferred.js"; -import { runCodeModeScriptHeadless, testing, type CodeModeHeadlessResult } from "./code-mode.js"; +import { runCodeModeScriptHeadless, type CodeModeHeadlessResult } from "./code-mode.js"; +import { testing } from "./code-mode.test-support.js"; import { createToolSearchCatalogRef, registerHeadlessToolSearchCatalog, diff --git a/src/agents/code-mode.test-support.ts b/src/agents/code-mode.test-support.ts new file mode 100644 index 00000000000..eded96e6307 --- /dev/null +++ b/src/agents/code-mode.test-support.ts @@ -0,0 +1,78 @@ +import "./code-mode.js"; +import type { ToolSearchToolContext } from "./tool-search.js"; + +type CodeModeConfig = { + enabled: boolean; + runtime: "quickjs-wasi"; + mode: "only"; + languages: ("javascript" | "typescript")[]; + timeoutMs: number; + memoryLimitBytes: number; + maxOutputBytes: number; + maxSnapshotBytes: number; + maxPendingToolCalls: number; + snapshotTtlSeconds: number; + searchDefaultLimit: number; + maxSearchLimit: number; +}; + +type CodeModeFailureCode = + | "aborted" + | "invalid_input" + | "runtime_unavailable" + | "timeout" + | "output_limit_exceeded" + | "snapshot_limit_exceeded" + | "internal_error"; + +type CodeModeWorkerResult = + | { status: "completed"; value: unknown; output: unknown[] } + | { + status: "waiting"; + snapshotBytes: Uint8Array; + pendingRequests: Array<{ id: string; method: string; args: unknown[] }>; + output: unknown[]; + } + | { status: "failed"; error: string; code: CodeModeFailureCode; output: unknown[] }; + +type CodeModeTestApi = { + activeRuns: Map; + resumingRunIds: Set; + createHeadlessAbortScope( + signal: AbortSignal | undefined, + wallClockMs: number, + ): { signal: AbortSignal; cleanup: () => void }; + normalizeCodeModeWorkerResult(result: CodeModeWorkerResult): CodeModeWorkerResult; + runCodeModeWorker( + workerData: unknown, + timeoutMs: number, + workerUrl?: URL, + signal?: AbortSignal, + ): Promise; + resolveCodeModeHeadlessConfig( + ctx: ToolSearchToolContext, + overrides?: Partial< + Pick< + CodeModeConfig, + | "timeoutMs" + | "memoryLimitBytes" + | "maxOutputBytes" + | "maxSnapshotBytes" + | "maxPendingToolCalls" + > + >, + ): CodeModeConfig; + resolveCodeModeWorkerUrl(currentModuleUrl: string): URL; + getTypescriptRuntimePromise(): Promise | null; + setTypescriptRuntimeForTest(runtime: typeof import("typescript") | null): void; +}; + +function getTestApi(): CodeModeTestApi { + const api = (globalThis as Record)[Symbol.for("openclaw.codeModeTestApi")]; + if (!api) { + throw new Error("code mode test API is unavailable"); + } + return api as CodeModeTestApi; +} + +export const testing = getTestApi(); diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index e1b4233889c..cec2a2f807d 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -19,8 +19,8 @@ import { CODE_MODE_WAIT_TOOL_NAME, createCodeModeTools, resolveCodeModeConfig, - testing, } from "./code-mode.js"; +import { testing } from "./code-mode.test-support.js"; import { createToolSearchCatalogRef, type ToolSearchCatalogRef } from "./tool-search.js"; import { TOOL_CALL_RAW_TOOL_NAME, diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index e0773c216bf..00d2be3ff47 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -1727,20 +1727,22 @@ export function addClientToolsToCodeModeCatalog(params: { } /** Test-only hooks and state accessors for Code Mode worker orchestration. */ -export const testing = { +const testing = { activeRuns, resumingRunIds, - codeModeWorkerUrl, createHeadlessAbortScope, normalizeCodeModeWorkerResult, runCodeModeWorker, resolveCodeModeHeadlessConfig, resolveCodeModeWorkerUrl, - resolveCodeModeConfig, getTypescriptRuntimePromise: (): Promise | null => typescriptRuntimeLoader.peek() ?? null, setTypescriptRuntimeForTest: (runtime: typeof import("typescript") | null) => { typescriptRuntimeForTest = runtime; }, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.codeModeTestApi")] = testing; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/command/attempt-execution.helpers.test-support.ts b/src/agents/command/attempt-execution.helpers.test-support.ts new file mode 100644 index 00000000000..9cefad03c5c --- /dev/null +++ b/src/agents/command/attempt-execution.helpers.test-support.ts @@ -0,0 +1,37 @@ +import type { ClaudeCliFallbackSeed } from "../../gateway/cli-session-history.js"; +import "./attempt-execution.helpers.js"; + +type AttemptExecutionHelpersTestApi = { + claudeCliSessionTranscriptPath(params: { + sessionId: string | undefined; + workspaceDir: string | undefined; + homeDir?: string; + }): string | null; + formatClaudeCliFallbackPrelude( + seed: ClaudeCliFallbackSeed, + options?: { charBudget?: number }, + ): string; +}; + +function getTestApi(): AttemptExecutionHelpersTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.attemptExecutionHelpersTestApi") + ]; + if (!api) { + throw new Error("attempt execution helpers test API is unavailable"); + } + return api as AttemptExecutionHelpersTestApi; +} + +export function claudeCliSessionTranscriptPath( + params: Parameters[0], +): string | null { + return getTestApi().claudeCliSessionTranscriptPath(params); +} + +export function formatClaudeCliFallbackPrelude( + seed: ClaudeCliFallbackSeed, + options?: { charBudget?: number }, +): string { + return getTestApi().formatClaudeCliFallbackPrelude(seed, options); +} diff --git a/src/agents/command/attempt-execution.helpers.ts b/src/agents/command/attempt-execution.helpers.ts index 1d6e8f39c44..73be3601174 100644 --- a/src/agents/command/attempt-execution.helpers.ts +++ b/src/agents/command/attempt-execution.helpers.ts @@ -93,7 +93,7 @@ export async function sessionFileHasContent(sessionFile: string | undefined): Pr } /** Resolves the expected Claude CLI transcript JSONL path for a session. */ -export function claudeCliSessionTranscriptPath(params: { +function claudeCliSessionTranscriptPath(params: { sessionId: string | undefined; workspaceDir: string | undefined; homeDir?: string; @@ -383,7 +383,7 @@ function formatFallbackTurns( * Returns an empty string when neither a summary nor any usable turn fits in * the budget; callers can treat that as "no context to seed". */ -export function formatClaudeCliFallbackPrelude( +function formatClaudeCliFallbackPrelude( seed: ClaudeCliFallbackSeed, options?: { charBudget?: number }, ): string { @@ -540,3 +540,9 @@ export function createAcpVisibleTextAccumulator() { }, }; } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.attemptExecutionHelpersTestApi") + ] = { claudeCliSessionTranscriptPath, formatClaudeCliFallbackPrelude }; +} diff --git a/src/agents/command/attempt-execution.test.ts b/src/agents/command/attempt-execution.test.ts index e118b4849cf..c9579d74955 100644 --- a/src/agents/command/attempt-execution.test.ts +++ b/src/agents/command/attempt-execution.test.ts @@ -8,13 +8,15 @@ import { cliBackendLog } from "../cli-runner/log.js"; import { buildClaudeCliFallbackContextPrelude, claudeCliSessionTranscriptHasContent, - claudeCliSessionTranscriptPath, claudeCliSessionTranscriptHasOrphanedToolUse, createAcpVisibleTextAccumulator, - formatClaudeCliFallbackPrelude, resolveFallbackRetryPrompt, sessionFileHasContent, } from "./attempt-execution.helpers.js"; +import { + claudeCliSessionTranscriptPath, + formatClaudeCliFallbackPrelude, +} from "./attempt-execution.helpers.test-support.js"; import { resolveClaudeCliProjectDirForWorkspace } from "./claude-cli-project-dir.js"; describe("resolveFallbackRetryPrompt", () => { diff --git a/src/agents/compaction-partial-summary.test.ts b/src/agents/compaction-partial-summary.test.ts index d564eb5af45..b861fecfce5 100644 --- a/src/agents/compaction-partial-summary.test.ts +++ b/src/agents/compaction-partial-summary.test.ts @@ -58,11 +58,11 @@ vi.mock("../infra/retry.js", async () => { }; }); -let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback; +let summarizeWithFallback: typeof import("./compaction.test-support.js").summarizeWithFallback; beforeAll(async () => { vi.resetModules(); - ({ summarizeWithFallback } = await import("./compaction.js")); + ({ summarizeWithFallback } = await import("./compaction.test-support.js")); }); describe("summarizeChunks partial summary preservation (#82952)", () => { diff --git a/src/agents/compaction.identifier-policy.test.ts b/src/agents/compaction.identifier-policy.test.ts index 5c7955abbc9..a6983269e8c 100644 --- a/src/agents/compaction.identifier-policy.test.ts +++ b/src/agents/compaction.identifier-policy.test.ts @@ -1,6 +1,6 @@ // Verifies summary instruction policy for preserving opaque identifiers. import { describe, expect, it } from "vitest"; -import { buildCompactionSummarizationInstructions } from "./compaction.js"; +import { buildCompactionSummarizationInstructions } from "./compaction.test-support.js"; describe("compaction identifier policy", () => { it("defaults to strict identifier preservation", () => { diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts index 81ccfa13e93..c234333c77c 100644 --- a/src/agents/compaction.identifier-preservation.test.ts +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -17,8 +17,8 @@ const mockGenerateSummary = vi.mocked(agentSessions.generateSummary); type SummarizeInStagesInput = Parameters[0]; const MESSAGE_TIME_BASE_MS = Date.UTC(2026, 0, 1); -const { buildCompactionSummarizationInstructions, summarizeInStages } = - await import("./compaction.js"); +const { buildCompactionSummarizationInstructions } = await import("./compaction.test-support.js"); +const { summarizeInStages } = await import("./compaction.js"); function makeMessage(index: number, size = 1200): AgentMessage { return { diff --git a/src/agents/compaction.summarize-fallback.test.ts b/src/agents/compaction.summarize-fallback.test.ts index 12b44f5325a..1d5aeefeab7 100644 --- a/src/agents/compaction.summarize-fallback.test.ts +++ b/src/agents/compaction.summarize-fallback.test.ts @@ -3,7 +3,7 @@ import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; import type { UserMessage } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { summarizeWithFallback } from "./compaction.js"; +import { summarizeWithFallback } from "./compaction.test-support.js"; const agentSessionMocks = vi.hoisted(() => ({ generateSummary: vi.fn(), diff --git a/src/agents/compaction.test-support.ts b/src/agents/compaction.test-support.ts new file mode 100644 index 00000000000..4c78da635be --- /dev/null +++ b/src/agents/compaction.test-support.ts @@ -0,0 +1,47 @@ +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; +import type { CompactionSummarizationInstructions } from "./compaction.js"; +import "./compaction.js"; + +type SummarizeWithFallbackParams = { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + headers?: Record; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + summarizationInstructions?: CompactionSummarizationInstructions; + previousSummary?: string; +}; + +type CompactionTestApi = { + buildCompactionSummarizationInstructions( + customInstructions?: string, + instructions?: CompactionSummarizationInstructions, + ): string | undefined; + summarizeWithFallback(params: SummarizeWithFallbackParams): Promise; +}; + +function getTestApi(): CompactionTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.compactionTestApi") + ]; + if (!api) { + throw new Error("compaction test API is unavailable"); + } + return api as CompactionTestApi; +} + +export function buildCompactionSummarizationInstructions( + customInstructions?: string, + instructions?: CompactionSummarizationInstructions, +): string | undefined { + return getTestApi().buildCompactionSummarizationInstructions(customInstructions, instructions); +} + +export async function summarizeWithFallback(params: SummarizeWithFallbackParams): Promise { + return await getTestApi().summarizeWithFallback(params); +} diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index df94604f6cb..c0001845487 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -19,7 +19,7 @@ vi.mock("./sessions/index.js", async () => { }); let isOversizedForSummary: typeof import("./compaction.js").isOversizedForSummary; -let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback; +let summarizeWithFallback: typeof import("./compaction.test-support.js").summarizeWithFallback; function makeAssistantToolCall(timestamp: number): AssistantMessage { return makeAgentAssistantMessage({ @@ -46,7 +46,8 @@ function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: describe("compaction toolResult details stripping", () => { beforeAll(async () => { - ({ isOversizedForSummary, summarizeWithFallback } = await import("./compaction.js")); + ({ isOversizedForSummary } = await import("./compaction.js")); + ({ summarizeWithFallback } = await import("./compaction.test-support.js")); }); beforeEach(() => { diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index 2bc44b7fc3e..b42f29bcf1f 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -104,7 +104,7 @@ function resolveIdentifierPreservationInstructions( } /** Combines identifier-preservation and caller-provided compaction instructions. */ -export function buildCompactionSummarizationInstructions( +function buildCompactionSummarizationInstructions( customInstructions?: string, instructions?: CompactionSummarizationInstructions, ): string | undefined { @@ -258,7 +258,7 @@ function generateSummary( * Summarize with progressive fallback for handling oversized messages. * If full summarization fails, tries partial summarization excluding oversized messages. */ -export async function summarizeWithFallback(params: { +async function summarizeWithFallback(params: { messages: AgentMessage[]; model: NonNullable; apiKey: string; @@ -453,3 +453,10 @@ export function resolveContextWindowTokens(model?: ExtensionContext["model"]): n (model as { contextTokens?: number } | undefined)?.contextTokens ?? model?.contextWindow; return Math.max(1, Math.floor(effective ?? DEFAULT_CONTEXT_TOKENS)); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.compactionTestApi")] = { + buildCompactionSummarizationInstructions, + summarizeWithFallback, + }; +} diff --git a/src/agents/config.ts b/src/agents/config.ts index 6b3c4cb7bc0..d50283cead8 100644 --- a/src/agents/config.ts +++ b/src/agents/config.ts @@ -63,25 +63,6 @@ function getPackageDir(): string { return currentDir; } -function getPackageSourceOrDistDir(): string { - const packageDir = getPackageDir(); - const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; - return join(packageDir, srcOrDist); -} - -/** - * Get path to built-in themes directory (shipped with package) - * - For Bun binary: theme/ next to executable - * - For Node.js (dist/): dist/agents/modes/interactive/theme/ - * - For tsx (src/): src/agents/modes/interactive/theme/ - */ -export function getThemesDir(): string { - if (isBunBinary) { - return join(getPackageDir(), "theme"); - } - return join(getPackageSourceOrDistDir(), "agents", "modes", "interactive", "theme"); -} - /** Get path to package.json */ function getPackageJsonPath(): string { return join(getPackageDir(), "package.json"); @@ -147,11 +128,6 @@ export function getAgentDir(): string { return join(homedir(), CONFIG_DIR_NAME, "agent"); } -/** Get path to user's custom themes directory */ -export function getCustomThemesDir(): string { - return join(getAgentDir(), "themes"); -} - /** Get path to managed binaries directory (fd, rg) */ export function getBinDir(): string { return join(getAgentDir(), "bin"); diff --git a/src/agents/embedded-agent-runner-extraparams-moonshot.test.ts b/src/agents/embedded-agent-runner-extraparams-moonshot.test.ts index 3a41771456d..aa8316d19d5 100644 --- a/src/agents/embedded-agent-runner-extraparams-moonshot.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-moonshot.test.ts @@ -8,7 +8,7 @@ import { resolveMoonshotThinkingType, } from "../llm/providers/stream-wrappers/moonshot-thinking.js"; import { runExtraParamsPayloadCase } from "./embedded-agent-runner-extraparams.test-support.js"; -import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.js"; +import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.test-support.js"; beforeEach(() => { // Moonshot thinking support lives in its provider wrapper, wired through the diff --git a/src/agents/embedded-agent-runner-extraparams-openrouter.test.ts b/src/agents/embedded-agent-runner-extraparams-openrouter.test.ts index 92d43e868bb..d3e48c1bfe9 100644 --- a/src/agents/embedded-agent-runner-extraparams-openrouter.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-openrouter.test.ts @@ -7,10 +7,8 @@ import { isProxyReasoningUnsupported, } from "../llm/providers/stream-wrappers/proxy.js"; import { runExtraParamsPayloadCase } from "./embedded-agent-runner-extraparams.test-support.js"; -import { - applyExtraParamsToAgent, - testing as extraParamsTesting, -} from "./embedded-agent-runner/extra-params.js"; +import { applyExtraParamsToAgent } from "./embedded-agent-runner/extra-params.js"; +import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.test-support.js"; beforeEach(() => { // OpenRouter behavior is supplied through the provider-runtime seam so tests diff --git a/src/agents/embedded-agent-runner-extraparams.test.ts b/src/agents/embedded-agent-runner-extraparams.test.ts index a9bd3be9e66..33af3264056 100644 --- a/src/agents/embedded-agent-runner-extraparams.test.ts +++ b/src/agents/embedded-agent-runner-extraparams.test.ts @@ -3,7 +3,7 @@ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { Context, Model, SimpleStreamOptions } from "openclaw/plugin-sdk/llm"; import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.js"; +import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.test-support.js"; vi.mock("../plugins/provider-hook-runtime.js", () => ({ clearProviderRuntimePluginCacheForTest: vi.fn(), diff --git a/src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts b/src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts index 07d4e86894d..1f4e3946a22 100644 --- a/src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts @@ -2,7 +2,8 @@ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; -import { testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js"; +import { applyExtraParamsToAgent } from "./extra-params.js"; +import { testing as extraParamsTesting } from "./extra-params.test-support.js"; import { log } from "./logger.js"; import { resolveCacheRetention } from "./prompt-cache-retention.js"; diff --git a/src/agents/embedded-agent-runner/extra-params.google.test.ts b/src/agents/embedded-agent-runner/extra-params.google.test.ts index ce1079cc2c5..5e431d1516b 100644 --- a/src/agents/embedded-agent-runner/extra-params.google.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.google.test.ts @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; import type { Model } from "../../llm/types.js"; -import { testing as extraParamsTesting } from "./extra-params.js"; -import { runExtraParamsCase } from "./extra-params.test-support.js"; +import { runExtraParamsCase, testing as extraParamsTesting } from "./extra-params.test-support.js"; vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); diff --git a/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts b/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts index 424f7af4146..88103479598 100644 --- a/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts @@ -3,12 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; import type { Model } from "../../llm/types.js"; import { - testing as extraParamsTesting, resolvePreparedExtraParams, resolveAgentTransportOverride, resolveExplicitSettingsTransport, } from "./extra-params.js"; -import { runExtraParamsCase } from "./extra-params.test-support.js"; +import { runExtraParamsCase, testing as extraParamsTesting } from "./extra-params.test-support.js"; vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); diff --git a/src/agents/embedded-agent-runner/extra-params.sampling.test.ts b/src/agents/embedded-agent-runner/extra-params.sampling.test.ts index d79c7737b95..47fbfa681ca 100644 --- a/src/agents/embedded-agent-runner/extra-params.sampling.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.sampling.test.ts @@ -3,11 +3,11 @@ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; import { - testing as extraParamsTesting, applyExtraParamsToAgent, resolveExtraParams, resolvePreparedExtraParams, } from "./extra-params.js"; +import { testing as extraParamsTesting } from "./extra-params.test-support.js"; vi.mock("./logger.js", () => ({ // Sampling tests assert call options only; silence warning/debug output from diff --git a/src/agents/embedded-agent-runner/extra-params.test-support.ts b/src/agents/embedded-agent-runner/extra-params.test-support.ts index 8d967051d60..245b7b6b663 100644 --- a/src/agents/embedded-agent-runner/extra-params.test-support.ts +++ b/src/agents/embedded-agent-runner/extra-params.test-support.ts @@ -1,10 +1,40 @@ // Shared harness for extra-params wrapper tests. import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { Context, Model, SimpleStreamOptions } from "../../llm/types.js"; +import type { + prepareProviderExtraParams, + resolveProviderExtraParamsForTransport, + wrapProviderStreamFn, +} from "../../plugins/provider-hook-runtime.js"; import type { StreamFn } from "../runtime/index.js"; -import { testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js"; +import { applyExtraParamsToAgent } from "./extra-params.js"; import type { ProviderThinkLevel } from "./utils.js"; +type ExtraParamsTestApi = { + setProviderRuntimeDepsForTest( + deps: + | Partial<{ + prepareProviderExtraParams: typeof prepareProviderExtraParams; + resolveProviderExtraParamsForTransport: typeof resolveProviderExtraParamsForTransport; + wrapProviderStreamFn: typeof wrapProviderStreamFn; + }> + | undefined, + ): void; + resetProviderRuntimeDepsForTest(): void; +}; + +function getTestApi(): ExtraParamsTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.extraParamsTestApi") + ]; + if (!api) { + throw new Error("extra params test API is unavailable"); + } + return api as ExtraParamsTestApi; +} + +export const testing = getTestApi(); + type ExtraParamsCapture> = { headers?: Record; options?: SimpleStreamOptions; @@ -60,7 +90,7 @@ export function runExtraParamsCase< const agent = { streamFn: baseStreamFn }; if (params.mockProviderRuntime === true) { - extraParamsTesting.setProviderRuntimeDepsForTest({ + testing.setProviderRuntimeDepsForTest({ prepareProviderExtraParams: () => undefined, resolveProviderExtraParamsForTransport: () => undefined, wrapProviderStreamFn: () => undefined, @@ -79,7 +109,7 @@ export function runExtraParamsCase< ); } finally { if (params.mockProviderRuntime === true) { - extraParamsTesting.resetProviderRuntimeDepsForTest(); + testing.resetProviderRuntimeDepsForTest(); } } diff --git a/src/agents/embedded-agent-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts index 6b9dc44bd2d..de256602097 100644 --- a/src/agents/embedded-agent-runner/extra-params.ts +++ b/src/agents/embedded-agent-runner/extra-params.ts @@ -58,7 +58,7 @@ const providerRuntimeDeps = { let preparedExtraParamsCache = new WeakMap>>(); const REQUEST_SCOPED_EXTRA_PARAM_KEYS = new Set(["response_format", "responseFormat", "stop"]); -export const testing = { +const testing = { setProviderRuntimeDepsForTest( deps: Partial | undefined, ): void { @@ -80,6 +80,10 @@ export const testing = { }, }; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.extraParamsTestApi")] = testing; +} + /** * Resolve provider-specific extra params from model config. * Used to pass through stream params like temperature/maxTokens. diff --git a/src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts b/src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts index 363ac8132e3..ce322c15df6 100644 --- a/src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts @@ -7,7 +7,7 @@ import type { Model, SimpleStreamOptions } from "../../llm/types.js"; vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); let runExtraParamsCase: typeof import("./extra-params.test-support.js").runExtraParamsCase; -let extraParamsTesting: typeof import("./extra-params.js").testing; +let extraParamsTesting: typeof import("./extra-params.test-support.js").testing; type ToolStreamCase = { applyProvider: string; @@ -32,8 +32,8 @@ function runToolStreamCase(params: ToolStreamCase) { describe("extra-params: provider tool_stream support", () => { beforeEach(async () => { - ({ testing: extraParamsTesting } = await import("./extra-params.js")); - ({ runExtraParamsCase } = await import("./extra-params.test-support.js")); + ({ runExtraParamsCase, testing: extraParamsTesting } = + await import("./extra-params.test-support.js")); extraParamsTesting.setProviderRuntimeDepsForTest({ prepareProviderExtraParams: (params) => { // Z.AI and xAI require streaming tool-call deltas unless config diff --git a/src/agents/embedded-agent-runner/run.lane-timeout.test.ts b/src/agents/embedded-agent-runner/run.lane-timeout.test.ts index d4357cad1da..0e2e8cf4f87 100644 --- a/src/agents/embedded-agent-runner/run.lane-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run.lane-timeout.test.ts @@ -1,30 +1,30 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { describe, expect, it } from "vitest"; import { DEFAULT_AGENT_TIMEOUT_MS } from "../timeout.js"; -import { testing } from "./run.js"; +import { + EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, + resolveEmbeddedRunLaneTimeoutMs, +} from "./run/lane-runtime.js"; describe("resolveEmbeddedRunLaneTimeoutMs", () => { it("adds queue grace to explicit run timeouts", () => { - expect(testing.resolveEmbeddedRunLaneTimeoutMs(60_000)).toBe( - 60_000 + testing.EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, + expect(resolveEmbeddedRunLaneTimeoutMs(60_000)).toBe( + 60_000 + EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, ); - expect(testing.resolveEmbeddedRunLaneTimeoutMs(60_000.9)).toBe( - 60_000 + testing.EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, + expect(resolveEmbeddedRunLaneTimeoutMs(60_000.9)).toBe( + 60_000 + EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, ); - expect(testing.resolveEmbeddedRunLaneTimeoutMs(DEFAULT_AGENT_TIMEOUT_MS + 60_000)).toBe( - DEFAULT_AGENT_TIMEOUT_MS + 60_000 + testing.EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, + expect(resolveEmbeddedRunLaneTimeoutMs(DEFAULT_AGENT_TIMEOUT_MS + 60_000)).toBe( + DEFAULT_AGENT_TIMEOUT_MS + 60_000 + EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, ); }); it("keeps the lane watchdog active when the run timeout is disabled", () => { - const defaultLaneTimeoutMs = - DEFAULT_AGENT_TIMEOUT_MS + testing.EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS; + const defaultLaneTimeoutMs = DEFAULT_AGENT_TIMEOUT_MS + EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS; - expect(testing.resolveEmbeddedRunLaneTimeoutMs(0)).toBe(defaultLaneTimeoutMs); - expect(testing.resolveEmbeddedRunLaneTimeoutMs(-1)).toBe(defaultLaneTimeoutMs); - expect(testing.resolveEmbeddedRunLaneTimeoutMs(Number.NaN)).toBe(defaultLaneTimeoutMs); - expect(testing.resolveEmbeddedRunLaneTimeoutMs(MAX_TIMER_TIMEOUT_MS)).toBe( - defaultLaneTimeoutMs, - ); + expect(resolveEmbeddedRunLaneTimeoutMs(0)).toBe(defaultLaneTimeoutMs); + expect(resolveEmbeddedRunLaneTimeoutMs(-1)).toBe(defaultLaneTimeoutMs); + expect(resolveEmbeddedRunLaneTimeoutMs(Number.NaN)).toBe(defaultLaneTimeoutMs); + expect(resolveEmbeddedRunLaneTimeoutMs(MAX_TIMER_TIMEOUT_MS)).toBe(defaultLaneTimeoutMs); }); }); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 897ea221e78..b4e6cfec3c3 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -1,14 +1,4 @@ /** * Public embedded-agent run entrypoint. */ -import { - EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, - resolveEmbeddedRunLaneTimeoutMs, -} from "./run/lane-runtime.js"; - export { runEmbeddedAgent } from "./run-orchestrator.js"; - -export const testing = { - EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS, - resolveEmbeddedRunLaneTimeoutMs, -}; diff --git a/src/agents/embedded-agent-runner/runs.test-support.ts b/src/agents/embedded-agent-runner/runs.test-support.ts new file mode 100644 index 00000000000..87a4ec77336 --- /dev/null +++ b/src/agents/embedded-agent-runner/runs.test-support.ts @@ -0,0 +1,17 @@ +import "./runs.js"; + +type EmbeddedRunsTestApi = { + resetActiveEmbeddedRuns(): void; +}; + +function getTestApi(): EmbeddedRunsTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.embeddedRunsTestApi") + ]; + if (!api) { + throw new Error("embedded runs test API is unavailable"); + } + return api as EmbeddedRunsTestApi; +} + +export const testing = getTestApi(); diff --git a/src/agents/embedded-agent-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts index 5ff02f3d896..5dcd5a77d17 100644 --- a/src/agents/embedded-agent-runner/runs.test.ts +++ b/src/agents/embedded-agent-runner/runs.test.ts @@ -6,10 +6,10 @@ import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - testing as replyRunTesting, createReplyOperation, isReplyRunActiveForSessionId, } from "../../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js"; import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.js"; import { resetDiagnosticRunActivityForTest } from "../../logging/diagnostic-run-activity.js"; import { markDiagnosticToolStartedForTest } from "../../logging/diagnostic-run-activity.test-support.js"; @@ -22,7 +22,6 @@ import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-trans import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js"; import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js"; import { - testing, abortAndDrainEmbeddedAgentRun, abortEmbeddedAgentRun, clearActiveEmbeddedRun, @@ -45,6 +44,7 @@ import { waitForActiveEmbeddedRuns, waitForEmbeddedAgentRunEnd, } from "./runs.js"; +import { testing } from "./runs.test-support.js"; type RunHandle = Parameters[1]; @@ -920,8 +920,7 @@ describe("embedded-agent runner run registry", () => { ); const handle = createRunHandle(); - runsA.testing.resetActiveEmbeddedRuns(); - runsB.testing.resetActiveEmbeddedRuns(); + testing.resetActiveEmbeddedRuns(); try { runsA.setActiveEmbeddedRun("session-shared", handle); @@ -930,8 +929,7 @@ describe("embedded-agent runner run registry", () => { runsB.clearActiveEmbeddedRun("session-shared", handle); expect(runsA.isEmbeddedAgentRunActive("session-shared")).toBe(false); } finally { - runsA.testing.resetActiveEmbeddedRuns(); - runsB.testing.resetActiveEmbeddedRuns(); + testing.resetActiveEmbeddedRuns(); } }); diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index b50530e91a6..634f58d5872 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -927,7 +927,7 @@ function forceClearEmbeddedAgentRun( return forceClearReplyRunBySessionId(sessionId, cause) || cleared; } -export const testing = { +const testing = { resetActiveEmbeddedRuns() { for (const waiters of EMBEDDED_RUN_WAITERS.values()) { for (const waiter of waiters) { @@ -947,4 +947,9 @@ export const testing = { ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.clear(); }, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.embeddedRunsTestApi")] = + testing; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/embedded-agent-runner/session-manager-cache.test-support.ts b/src/agents/embedded-agent-runner/session-manager-cache.test-support.ts new file mode 100644 index 00000000000..de097ef7dec --- /dev/null +++ b/src/agents/embedded-agent-runner/session-manager-cache.test-support.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import "./session-manager-cache.js"; + +type SessionManagerCache = { + clear(): void; + isSessionManagerCached(sessionFile: string): boolean; + keys(): string[]; + prewarmSessionFile(sessionFile: string): Promise; + trackSessionManagerAccess(sessionFile: string): void; +}; + +type SessionManagerCacheTestApi = { + createSessionManagerCache(options?: { + clock?: () => number; + fsModule?: Pick; + ttlMs?: number | (() => number); + }): SessionManagerCache; +}; + +function getTestApi(): SessionManagerCacheTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.sessionManagerCacheTestApi") + ]; + if (!api) { + throw new Error("session manager cache test API is unavailable"); + } + return api as SessionManagerCacheTestApi; +} + +export const createSessionManagerCache: SessionManagerCacheTestApi["createSessionManagerCache"] = ( + options, +) => getTestApi().createSessionManagerCache(options); diff --git a/src/agents/embedded-agent-runner/session-manager-cache.test.ts b/src/agents/embedded-agent-runner/session-manager-cache.test.ts index 1c84720c653..70ebdcce56c 100644 --- a/src/agents/embedded-agent-runner/session-manager-cache.test.ts +++ b/src/agents/embedded-agent-runner/session-manager-cache.test.ts @@ -1,7 +1,7 @@ // Session manager cache tests cover TTL pruning and explicit cache-disable // behavior for transcript-backed session managers. import { describe, expect, it } from "vitest"; -import { createSessionManagerCache } from "./session-manager-cache.js"; +import { createSessionManagerCache } from "./session-manager-cache.test-support.js"; describe("session manager cache", () => { it("prunes expired entries during later cache activity even without revisiting them", () => { diff --git a/src/agents/embedded-agent-runner/session-manager-cache.ts b/src/agents/embedded-agent-runner/session-manager-cache.ts index 9b0eea0dca2..876e708b3d8 100644 --- a/src/agents/embedded-agent-runner/session-manager-cache.ts +++ b/src/agents/embedded-agent-runner/session-manager-cache.ts @@ -25,7 +25,7 @@ type SessionManagerCache = { trackSessionManagerAccess: (sessionFile: string) => void; }; -export function createSessionManagerCache(options?: { +function createSessionManagerCache(options?: { clock?: () => number; fsModule?: Pick; ttlMs?: number | (() => number); @@ -87,3 +87,8 @@ export function trackSessionManagerAccess(sessionFile: string): void { export async function prewarmSessionFile(sessionFile: string): Promise { await sessionManagerCache.prewarmSessionFile(sessionFile); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.sessionManagerCacheTestApi")] = + { createSessionManagerCache }; +} diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts b/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts new file mode 100644 index 00000000000..df03ae7ad88 --- /dev/null +++ b/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts @@ -0,0 +1,63 @@ +import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js"; +import type { AssistantPhase } from "../shared/chat-message-content.js"; +import "./embedded-agent-subscribe.handlers.messages.js"; +import type { EmbeddedAgentSubscribeState } from "./embedded-agent-subscribe.handlers.types.js"; + +type AssistantStreamDataParams = { + text?: string; + delta?: string; + replace?: boolean; + mediaUrls?: string[]; + mediaUrl?: string; + phase?: AssistantPhase; + itemId?: string; +}; + +type AssistantStreamData = { + text: string; + delta: string; + replace?: true; + mediaUrls?: string[]; + phase?: AssistantPhase; + itemId?: string; +}; + +type EmbeddedSubscribeMessagesTestApi = { + buildAssistantStreamData(params: AssistantStreamDataParams): AssistantStreamData; + recordPendingAssistantReplyDirectives( + state: Pick, + parsed: ReplyDirectiveParseResult | null | undefined, + ): void; + resolveSilentReplyFallbackText(params: { + text: unknown; + messagingToolSentTexts: string[]; + }): string; +}; + +function getTestApi(): EmbeddedSubscribeMessagesTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.embeddedSubscribeMessagesTestApi") + ]; + if (!api) { + throw new Error("embedded subscribe messages test API is unavailable"); + } + return api as EmbeddedSubscribeMessagesTestApi; +} + +export function buildAssistantStreamData(params: AssistantStreamDataParams): AssistantStreamData { + return getTestApi().buildAssistantStreamData(params); +} + +export function recordPendingAssistantReplyDirectives( + state: Pick, + parsed: ReplyDirectiveParseResult | null | undefined, +): void { + getTestApi().recordPendingAssistantReplyDirectives(state, parsed); +} + +export function resolveSilentReplyFallbackText(params: { + text: unknown; + messagingToolSentTexts: string[]; +}): string { + return getTestApi().resolveSilentReplyFallbackText(params); +} diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.test.ts b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts index 9c588d2ce1a..5b91cf650cd 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it, vi } from "vitest"; import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js"; import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js"; import { - buildAssistantStreamData, consumePendingAssistantReplyDirectivesIntoReply, consumePendingToolMediaIntoReply, consumePendingToolMediaReply, @@ -12,9 +11,12 @@ import { handleMessageUpdate, hasAssistantVisibleReply, readPendingToolMediaReply, +} from "./embedded-agent-subscribe.handlers.messages.js"; +import { + buildAssistantStreamData, recordPendingAssistantReplyDirectives, resolveSilentReplyFallbackText, -} from "./embedded-agent-subscribe.handlers.messages.js"; +} from "./embedded-agent-subscribe.handlers.messages.test-support.js"; import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; import { createOpenAiResponsesPartial, diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.ts b/src/agents/embedded-agent-subscribe.handlers.messages.ts index d72439ef4fa..06798f0e1f0 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.ts @@ -409,7 +409,7 @@ function copyPartialBlockState( } /** Replaces a silent-reply token with the latest sent messaging-tool text when available. */ -export function resolveSilentReplyFallbackText(params: { +function resolveSilentReplyFallbackText(params: { text: unknown; messagingToolSentTexts: string[]; }): string { @@ -612,7 +612,7 @@ function resolveStreamingReplyText(params: { } /** Records parsed reply directives until a sendable reply payload is built. */ -export function recordPendingAssistantReplyDirectives( +function recordPendingAssistantReplyDirectives( state: Pick, parsed: ReplyDirectiveParseResult | null | undefined, ) { @@ -666,7 +666,7 @@ export function hasAssistantVisibleReply(params: { } /** Builds normalized stream payload data for assistant visible output. */ -export function buildAssistantStreamData(params: { +function buildAssistantStreamData(params: { text?: string; delta?: string; replace?: boolean; @@ -1133,6 +1133,16 @@ export function handleMessageUpdate( } } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.embeddedSubscribeMessagesTestApi") + ] = { + buildAssistantStreamData, + recordPendingAssistantReplyDirectives, + resolveSilentReplyFallbackText, + }; +} + /** Handles assistant message-end finalization, block flush, and usage commit. */ export function handleMessageEnd( ctx: EmbeddedAgentSubscribeContext, diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts index d8efa58c393..ff904993914 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -12,8 +12,11 @@ import { buildBlockedToolResult, recordAdjustedParamsForToolCall, recordStructuredReplayTrustForToolCall, - testing as beforeToolCallTesting, } from "./agent-tools.before-tool-call.js"; +import { + adjustedParamsByToolCallId, + buildAdjustedParamsKey, +} from "./agent-tools.before-tool-call.state.js"; import type { MessagingToolSend } from "./embedded-agent-messaging.types.js"; import { buildEmbeddedRunPayloads } from "./embedded-agent-runner/run/payloads.js"; import { @@ -30,6 +33,8 @@ type ToolExecutionStartEvent = Extract; type PayloadToolMetas = Parameters[0]["toolMetas"]; +const beforeToolCallTesting = { adjustedParamsByToolCallId, buildAdjustedParamsKey }; + function createTestContext(): { ctx: ToolHandlerContext; warn: ReturnType; diff --git a/src/agents/embedded-agent-subscribe.tools.media.test.ts b/src/agents/embedded-agent-subscribe.tools.media.test.ts index 73353d00208..a52b73d7b35 100644 --- a/src/agents/embedded-agent-subscribe.tools.media.test.ts +++ b/src/agents/embedded-agent-subscribe.tools.media.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from "vitest"; import { extractToolResultMediaArtifact, filterToolResultMediaUrls, - isToolResultMediaTrusted, } from "./embedded-agent-subscribe.tools.js"; +import { isToolResultMediaTrusted } from "./embedded-agent-subscribe.tools.test-support.js"; describe("extractToolResultMediaArtifact", () => { it("returns undefined for null/undefined", () => { diff --git a/src/agents/embedded-agent-subscribe.tools.test-support.ts b/src/agents/embedded-agent-subscribe.tools.test-support.ts new file mode 100644 index 00000000000..6e12209eca5 --- /dev/null +++ b/src/agents/embedded-agent-subscribe.tools.test-support.ts @@ -0,0 +1,27 @@ +import "./embedded-agent-subscribe.tools.js"; + +type EmbeddedSubscribeToolsTestApi = { + isToolResultMediaTrusted( + toolName?: string, + result?: unknown, + trustedLocalMediaToolNames?: ReadonlySet, + ): boolean; +}; + +function getTestApi(): EmbeddedSubscribeToolsTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.embeddedSubscribeToolsTestApi") + ]; + if (!api) { + throw new Error("embedded subscribe tools test API is unavailable"); + } + return api as EmbeddedSubscribeToolsTestApi; +} + +export function isToolResultMediaTrusted( + toolName?: string, + result?: unknown, + trustedLocalMediaToolNames?: ReadonlySet, +): boolean { + return getTestApi().isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames); +} diff --git a/src/agents/embedded-agent-subscribe.tools.ts b/src/agents/embedded-agent-subscribe.tools.ts index 82dc4099c92..c86363f5e38 100644 --- a/src/agents/embedded-agent-subscribe.tools.ts +++ b/src/agents/embedded-agent-subscribe.tools.ts @@ -643,7 +643,7 @@ function isExternalToolResult(result: unknown): boolean { return typeof details.mcpServer === "string" || typeof details.mcpTool === "string"; } -export function isToolResultMediaTrusted( +function isToolResultMediaTrusted( toolName?: string, result?: unknown, trustedLocalMediaToolNames?: ReadonlySet, @@ -658,6 +658,12 @@ export function isToolResultMediaTrusted( return isCoreToolResultMediaTrustedName(toolName); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.embeddedSubscribeToolsTestApi") + ] = { isToolResultMediaTrusted }; +} + function isTrustedOwnedTtsLocalMedia( toolName: string | undefined, result: unknown, diff --git a/src/agents/harness/lifecycle-hook-helpers.test.ts b/src/agents/harness/lifecycle-hook-helpers.test.ts index 581e43a0dd2..3e41be0cb42 100644 --- a/src/agents/harness/lifecycle-hook-helpers.test.ts +++ b/src/agents/harness/lifecycle-hook-helpers.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { awaitAgentHarnessAgentEndHook, - clearAgentHarnessFinalizeRetryBudget, runAgentHarnessAgentEndHook, runAgentHarnessBeforeAgentFinalizeHook, runAgentHarnessLlmInputHook, @@ -30,7 +29,7 @@ const EVENT = { describe("agent harness lifecycle hook helpers", () => { afterEach(() => { - clearAgentHarnessFinalizeRetryBudget(); + Reflect.deleteProperty(globalThis, Symbol.for("openclaw.pluginFinalizeRetryBudget")); }); it("ignores legacy hook runners that advertise llm_input without a runner method", () => { @@ -126,83 +125,6 @@ describe("agent harness lifecycle hook helpers", () => { ).resolves.toEqual({ action: "continue" }); }); - it("clears finalize retry budgets by run id", async () => { - const hookRunner = { - hasHooks: () => true, - runBeforeAgentFinalize: vi.fn().mockResolvedValue({ - action: "revise", - retry: { - instruction: "revise once", - idempotencyKey: "stable", - maxAttempts: 1, - }, - }), - }; - - // Retry budgets are per run. A finalize hook may ask for exactly one - // revision, and clearing the run id should make that run eligible again. - await expect( - runAgentHarnessBeforeAgentFinalizeHook({ - event: EVENT, - ctx: { runId: "run-1", sessionKey: "agent:main:session-1" }, - hookRunner: hookRunner as never, - }), - ).resolves.toEqual({ action: "revise", reason: "revise once" }); - await expect( - runAgentHarnessBeforeAgentFinalizeHook({ - event: EVENT, - ctx: { runId: "run-1", sessionKey: "agent:main:session-1" }, - hookRunner: hookRunner as never, - }), - ).resolves.toEqual({ action: "continue" }); - - clearAgentHarnessFinalizeRetryBudget({ runId: "run-1" }); - - await expect( - runAgentHarnessBeforeAgentFinalizeHook({ - event: EVENT, - ctx: { runId: "run-1", sessionKey: "agent:main:session-1" }, - hookRunner: hookRunner as never, - }), - ).resolves.toEqual({ action: "revise", reason: "revise once" }); - }); - - it("does not clear finalize retry budgets for runs that only share a prefix", async () => { - const hookRunner = { - hasHooks: () => true, - runBeforeAgentFinalize: vi.fn().mockResolvedValue({ - action: "revise", - retry: { - instruction: "revise child once", - idempotencyKey: "stable", - maxAttempts: 1, - }, - }), - }; - const childEvent = { - ...EVENT, - runId: "run:child", - }; - - await expect( - runAgentHarnessBeforeAgentFinalizeHook({ - event: childEvent, - ctx: { runId: "run:child", sessionKey: "agent:main:session-1" }, - hookRunner: hookRunner as never, - }), - ).resolves.toEqual({ action: "revise", reason: "revise child once" }); - - clearAgentHarnessFinalizeRetryBudget({ runId: "run" }); - - await expect( - runAgentHarnessBeforeAgentFinalizeHook({ - event: childEvent, - ctx: { runId: "run:child", sessionKey: "agent:main:session-1" }, - hookRunner: hookRunner as never, - }), - ).resolves.toEqual({ action: "continue" }); - }); - it("keys finalize retry budgets by context run id when the event omits run id", async () => { const hookRunner = { hasHooks: () => true, @@ -235,16 +157,6 @@ describe("agent harness lifecycle hook helpers", () => { hookRunner: hookRunner as never, }), ).resolves.toEqual({ action: "continue" }); - - clearAgentHarnessFinalizeRetryBudget({ runId: "run-from-context" }); - - await expect( - runAgentHarnessBeforeAgentFinalizeHook({ - event: eventWithoutRunId, - ctx: { runId: "run-from-context", sessionKey: "agent:main:shared-session" }, - hookRunner: hookRunner as never, - }), - ).resolves.toEqual({ action: "revise", reason: "revise from context run" }); }); it("preserves merged revise reasons when retry metadata is present", async () => { diff --git a/src/agents/harness/lifecycle-hook-helpers.ts b/src/agents/harness/lifecycle-hook-helpers.ts index b8a60669304..34c433e0b3e 100644 --- a/src/agents/harness/lifecycle-hook-helpers.ts +++ b/src/agents/harness/lifecycle-hook-helpers.ts @@ -64,16 +64,6 @@ function buildFinalizeRetryInstructionKey(instruction: string): string { return `instruction:${createHash("sha256").update(instruction).digest("hex")}`; } -/** Clears before-finalize retry budgets globally or for one run. */ -export function clearAgentHarnessFinalizeRetryBudget(params?: { runId?: string }): void { - const budget = getFinalizeRetryBudget(); - if (!params?.runId) { - budget.clear(); - return; - } - budget.delete(params.runId); -} - /** Dispatches best-effort LLM input hooks for a harness attempt. */ export function runAgentHarnessLlmInputHook(params: { event: PluginHookLlmInputEvent; diff --git a/src/agents/harness/tool-surface-bridge.test.ts b/src/agents/harness/tool-surface-bridge.test.ts index 47d6c53bb5e..e305492532d 100644 --- a/src/agents/harness/tool-surface-bridge.test.ts +++ b/src/agents/harness/tool-surface-bridge.test.ts @@ -3,12 +3,12 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { runWithAgentRingZeroTools } from "../agent-tools.ring-zero-context.js"; import { createStubTool } from "../test-helpers/agent-tool-stubs.js"; import { - testing, TOOL_CALL_RAW_TOOL_NAME, TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_CODE_MODE_TOOL_NAME, TOOL_SEARCH_RAW_TOOL_NAME, } from "../tool-search.js"; +import { testing } from "../tool-search.test-support.js"; import { createAgentHarnessToolSurfaceRuntime } from "./tool-surface-bridge.js"; function tools(names: string[]) { diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index c2d8c55be12..b808e40e6e7 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -131,7 +131,7 @@ let mockedResolveAuthProfileOrder: ReturnType< typeof vi.mocked >; let runWithModelFallback: ModelFallbackModule["runWithModelFallback"]; -let modelFallbackTesting: ModelFallbackModule["testing"]; +let resolveCooldownDecision: (typeof import("./model-fallback.test-support.js"))["resolveCooldownDecision"]; let probeThrottleInternals: ModelFallbackModule["probeThrottleInternals"]; let resetLogger: LoggerModule["resetLogger"]; let setLoggerOverride: LoggerModule["setLoggerOverride"]; @@ -147,6 +147,7 @@ async function loadModelFallbackProbeModules() { const authProfilesOrderModule = await import("./auth-profiles/order.js"); const loggerModule = await import("../logging/logger.js"); const modelFallbackModule = await import("./model-fallback.js"); + const modelFallbackTestSupport = await import("./model-fallback.test-support.js"); mockedEnsureAuthProfileStore = vi.mocked(authProfilesStoreModule.ensureAuthProfileStore); mockedHasAnyAuthProfileStoreSource = vi.mocked( authProfilesSourceCheckModule.hasAnyAuthProfileStoreSource, @@ -158,7 +159,7 @@ async function loadModelFallbackProbeModules() { ); mockedResolveAuthProfileOrder = vi.mocked(authProfilesOrderModule.resolveAuthProfileOrder); runWithModelFallback = modelFallbackModule.runWithModelFallback; - modelFallbackTesting = modelFallbackModule.testing; + resolveCooldownDecision = modelFallbackTestSupport.resolveCooldownDecision; probeThrottleInternals = modelFallbackModule.probeThrottleInternals; resetLogger = loggerModule.resetLogger; setLoggerOverride = loggerModule.setLoggerOverride; @@ -292,7 +293,7 @@ describe("runWithModelFallback – probe logic", () => { if (params.usageStats) { authStore.usageStats = params.usageStats; } - return modelFallbackTesting.resolveCooldownDecision({ + return resolveCooldownDecision({ candidate: OPENAI_PROBE_CANDIDATE, isPrimary: params.isPrimary ?? true, requestedModel: params.requestedModel ?? true, @@ -302,16 +303,14 @@ describe("runWithModelFallback – probe logic", () => { authRuntime: { getSoonestCooldownExpiry: mockedGetSoonestCooldownExpiry, resolveProfilesUnavailableReason: mockedResolveProfilesUnavailableReason, - } as unknown as Parameters< - typeof modelFallbackTesting.resolveCooldownDecision - >[0]["authRuntime"], + } as unknown as Parameters[0]["authRuntime"], authStore, profileIds: ["openai-profile-1"], }); } function expectOpenAiProbeSuspension( - decision: ReturnType, + decision: ReturnType, reason: "rate_limit" | "billing", ) { expect(decision).toEqual({ diff --git a/src/agents/model-fallback.test-support.ts b/src/agents/model-fallback.test-support.ts new file mode 100644 index 00000000000..2d4cc455484 --- /dev/null +++ b/src/agents/model-fallback.test-support.ts @@ -0,0 +1,47 @@ +import type { AuthProfileStore } from "./auth-profiles/types.js"; +import type { FailoverReason } from "./embedded-agent-helpers/types.js"; +import type { ModelCandidate } from "./model-fallback.types.js"; +import "./model-fallback.js"; + +type CooldownDecision = + | { type: "skip"; reason: FailoverReason; error: string } + | { type: "attempt"; reason: FailoverReason; markProbe: boolean } + | { type: "suspend_lanes"; reason: FailoverReason; leaderCandidate?: ModelCandidate }; + +type ModelFallbackTestApi = { + resolveCooldownDecision(params: { + candidate: ModelCandidate; + isPrimary: boolean; + requestedModel: boolean; + hasFallbackCandidates: boolean; + now: number; + probeThrottleKey: string; + authRuntime: typeof import("./model-fallback-auth.runtime.js"); + authStore: AuthProfileStore; + profileIds: string[]; + }): CooldownDecision; + shouldDiscardDeferredSessionSuspension(params: { + error: unknown; + abortSignal?: AbortSignal; + }): boolean; +}; + +function getTestApi(): ModelFallbackTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.modelFallbackTestApi") + ]; + if (!api) { + throw new Error("model fallback test API is unavailable"); + } + return api as ModelFallbackTestApi; +} + +export const resolveCooldownDecision: ModelFallbackTestApi["resolveCooldownDecision"] = (params) => + getTestApi().resolveCooldownDecision(params); + +export function shouldDiscardDeferredSessionSuspension(params: { + error: unknown; + abortSignal?: AbortSignal; +}): boolean { + return getTestApi().shouldDiscardDeferredSessionSuspension(params); +} diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 4bf9e15e9dc..24baf56e436 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -30,18 +30,26 @@ import type { AgentHarness } from "./harness/types.js"; import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; import { isFallbackSummaryError, - testing, + resolveModelCandidateChain, runWithImageModelFallback, runWithModelFallback as runWithModelFallbackBase, } from "./model-fallback.js"; +import { shouldDiscardDeferredSessionSuspension } from "./model-fallback.test-support.js"; import { createAgentRunDirectAbortError, createAgentRunRestartAbortError, resolveAgentRunErrorLifecycleFields, } from "./run-termination.js"; +import { resolveSessionSuspensionReason } from "./session-suspension.js"; import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; +const testing = { + resolveFallbackCandidates: resolveModelCandidateChain, + resolveSessionSuspensionReason, + shouldDiscardDeferredSessionSuspension, +}; + type ProviderModelNormalizationParams = { provider: string; context: { modelId: string } }; function makeCommandLaneTaskTimeoutError(lane: string, timeoutMs: number): Error { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 61acdda3dbe..8dab7dfef21 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -889,14 +889,6 @@ export function resolveImageFallbackDefaultProvider(cfg: OpenClawConfig | undefi return DEFAULT_PROVIDER; } -export const testing = { - resolveFallbackCandidates: resolveModelCandidateChain, - resolveImageFallbackCandidates, - resolveCooldownDecision, - resolveSessionSuspensionReason, - shouldDiscardDeferredSessionSuspension, -} as const; - export function resolveModelCandidateChain( params: { cfg: OpenClawConfig | undefined; @@ -1412,6 +1404,13 @@ function shouldDiscardDeferredSessionSuspension(params: { ); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.modelFallbackTestApi")] = { + resolveCooldownDecision, + shouldDiscardDeferredSessionSuspension, + }; +} + export async function runWithModelFallback( params: RunWithModelFallbackParams, ): Promise> { diff --git a/src/agents/modes/interactive/theme/theme.ts b/src/agents/modes/interactive/theme/theme.ts index 7dcbe665596..9cc44cbaae3 100644 --- a/src/agents/modes/interactive/theme/theme.ts +++ b/src/agents/modes/interactive/theme/theme.ts @@ -4,14 +4,11 @@ * Validates theme JSON, resolves color variables, watches custom theme files, and exposes terminal styling helpers. */ import * as fs from "node:fs"; -import * as path from "node:path"; import { getCapabilities } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; -import { getCustomThemesDir, getThemesDir } from "../../../config.js"; import type { SourceInfo } from "../../../sessions/source-info.js"; -import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.js"; import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.js"; // ============================================================================ @@ -456,21 +453,6 @@ export class Theme { // Theme Loading // ============================================================================ -let BUILTIN_THEMES: Record | undefined; - -function getBuiltinThemes(): Record { - if (!BUILTIN_THEMES) { - const themesDir = getThemesDir(); - const darkPath = path.join(themesDir, "dark.json"); - const lightPath = path.join(themesDir, "light.json"); - BUILTIN_THEMES = { - dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, - light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, - }; - } - return BUILTIN_THEMES; -} - function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); @@ -522,31 +504,6 @@ function parseThemeJsonContent(label: string, content: string): ThemeJson { return parseThemeJson(label, json); } -function loadThemeJson(name: string): ThemeJson { - const builtinThemes = getBuiltinThemes(); - if (name in builtinThemes) { - const builtinTheme = builtinThemes[name]; - if (builtinTheme) { - return builtinTheme; - } - } - const registeredTheme = registeredThemes.get(name); - if (registeredTheme?.sourcePath) { - const content = fs.readFileSync(registeredTheme.sourcePath, "utf-8"); - return parseThemeJsonContent(registeredTheme.sourcePath, content); - } - if (registeredTheme) { - throw new Error(`Theme "${name}" does not have a source path for export`); - } - const customThemesDir = getCustomThemesDir(); - const themePath = path.join(customThemesDir, `${name}.json`); - if (!fs.existsSync(themePath)) { - throw new Error(`Theme not found: ${name}`); - } - const content = fs.readFileSync(themePath, "utf-8"); - return parseThemeJsonContent(name, content); -} - function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color"); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); @@ -579,15 +536,6 @@ export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme { return createTheme(themeJson, mode, themePath); } -function loadTheme(name: string, mode?: ColorMode): Theme { - const registeredTheme = registeredThemes.get(name); - if (registeredTheme) { - return registeredTheme; - } - const themeJson = loadThemeJson(name); - return createTheme(themeJson, mode); -} - // ============================================================================ // Global Theme Instance // ============================================================================ @@ -607,116 +555,6 @@ export const theme: Theme = new Proxy({} as Theme, { }, }); -function setGlobalTheme(t: Theme): void { - (globalThis as Record)[THEME_KEY] = t; -} - -let currentThemeName: string | undefined; -let themeWatcher: fs.FSWatcher | undefined; -let themeReloadTimer: NodeJS.Timeout | undefined; -const registeredThemes = new Map(); - -export function setTheme( - name: string, - enableWatcher = false, -): { success: boolean; error?: string } { - currentThemeName = name; - try { - setGlobalTheme(loadTheme(name)); - if (enableWatcher) { - startThemeWatcher(); - } - return { success: true }; - } catch (error) { - // Theme is invalid - fall back to dark theme - currentThemeName = "dark"; - setGlobalTheme(loadTheme("dark")); - // Don't start watcher for fallback theme - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -function startThemeWatcher(): void { - stopThemeWatcher(); - - // Only watch if it's a custom theme (not built-in) - if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { - return; - } - - const customThemesDir = getCustomThemesDir(); - const watchedThemeName = currentThemeName; - const watchedFileName = `${watchedThemeName}.json`; - const themeFile = path.join(customThemesDir, watchedFileName); - - // Only watch if the file exists - if (!fs.existsSync(themeFile)) { - return; - } - - const scheduleReload = () => { - if (themeReloadTimer) { - clearTimeout(themeReloadTimer); - } - themeReloadTimer = setTimeout(() => { - themeReloadTimer = undefined; - - // Ignore stale timers after switching themes or stopping the watcher - if (currentThemeName !== watchedThemeName) { - return; - } - - // Keep the last successfully loaded theme active if the file is temporarily missing - if (!fs.existsSync(themeFile)) { - return; - } - - try { - // Reload the theme from disk and refresh the registry cache - const reloadedTheme = loadThemeFromPath(themeFile); - registeredThemes.set(watchedThemeName, reloadedTheme); - setGlobalTheme(reloadedTheme); - } catch { - // Ignore errors (file might be in invalid state while being edited) - } - }, 100); - }; - - themeWatcher = - watchWithErrorHandler( - customThemesDir, - (_eventType, filename) => { - if (currentThemeName !== watchedThemeName) { - return; - } - if (!filename) { - scheduleReload(); - return; - } - if (filename !== watchedFileName) { - return; - } - scheduleReload(); - }, - () => { - closeWatcher(themeWatcher); - themeWatcher = undefined; - }, - ) ?? undefined; -} - -function stopThemeWatcher(): void { - if (themeReloadTimer) { - clearTimeout(themeReloadTimer); - themeReloadTimer = undefined; - } - closeWatcher(themeWatcher); - themeWatcher = undefined; -} - // ============================================================================ // HTML Export Helpers // ============================================================================ @@ -855,4 +693,3 @@ export function getLanguageFromPath(filePath: string): string | undefined { return extToLang[ext]; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/openai-completions-compat.test-support.ts b/src/agents/openai-completions-compat.test-support.ts new file mode 100644 index 00000000000..77f8e52f209 --- /dev/null +++ b/src/agents/openai-completions-compat.test-support.ts @@ -0,0 +1,36 @@ +import type { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; +import type { ProviderEndpointClass } from "./provider-attribution.js"; +import "./openai-completions-compat.js"; + +type OpenAICompletionsCompatDefaultsInput = { + provider?: string; + endpointClass: ProviderEndpointClass; + knownProviderFamily: string; + supportsNativeStreamingUsageCompat?: boolean; + supportsOpenAICompletionsStreamingUsageCompat?: boolean; + usesExplicitProxyLikeEndpoint?: boolean; +}; + +type OpenAICompletionsCompatDefaults = ReturnType["defaults"]; + +type OpenAICompletionsCompatTestApi = { + resolveOpenAICompletionsCompatDefaults( + input: OpenAICompletionsCompatDefaultsInput, + ): OpenAICompletionsCompatDefaults; +}; + +function getTestApi(): OpenAICompletionsCompatTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.openAICompletionsCompatTestApi") + ]; + if (!api) { + throw new Error("OpenAI completions compat test API is unavailable"); + } + return api as OpenAICompletionsCompatTestApi; +} + +export function resolveOpenAICompletionsCompatDefaults( + input: OpenAICompletionsCompatDefaultsInput, +): OpenAICompletionsCompatDefaults { + return getTestApi().resolveOpenAICompletionsCompatDefaults(input); +} diff --git a/src/agents/openai-completions-compat.test.ts b/src/agents/openai-completions-compat.test.ts index d079264cc1b..02539ef54f2 100644 --- a/src/agents/openai-completions-compat.test.ts +++ b/src/agents/openai-completions-compat.test.ts @@ -1,9 +1,7 @@ // Verifies OpenAI-compatible endpoint defaults for streaming usage and reasoning payloads. import { describe, expect, it } from "vitest"; -import { - detectOpenAICompletionsCompat, - resolveOpenAICompletionsCompatDefaults, -} from "./openai-completions-compat.js"; +import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; +import { resolveOpenAICompletionsCompatDefaults } from "./openai-completions-compat.test-support.js"; describe("resolveOpenAICompletionsCompatDefaults", () => { it("keeps streaming usage enabled for provider-declared compatible endpoints", () => { diff --git a/src/agents/openai-completions-compat.ts b/src/agents/openai-completions-compat.ts index a92a11bbfbe..820ecd889d8 100644 --- a/src/agents/openai-completions-compat.ts +++ b/src/agents/openai-completions-compat.ts @@ -40,7 +40,7 @@ function isDefaultRouteProvider(provider: string | undefined, ...ids: string[]) } /** Resolves default request flags for an OpenAI-compatible completions endpoint. */ -export function resolveOpenAICompletionsCompatDefaults( +function resolveOpenAICompletionsCompatDefaults( input: OpenAICompletionsCompatDefaultsInput, ): OpenAICompletionsCompatDefaults { const { @@ -169,3 +169,9 @@ export function detectOpenAICompletionsCompat( }), }; } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.openAICompletionsCompatTestApi") + ] = { resolveOpenAICompletionsCompatDefaults }; +} diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 4116a2b33b8..27d67b6c704 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -38,16 +38,14 @@ vi.mock("../config/config.js", () => ({ import "./test-helpers/fast-openclaw-tools-sessions.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { - testing as embeddedRunsTesting, - setActiveEmbeddedRun, -} from "./embedded-agent-runner/runs.js"; +import { setActiveEmbeddedRun } from "./embedded-agent-runner/runs.js"; +import { testing as embeddedRunsTesting } from "./embedded-agent-runner/runs.test-support.js"; import { testing as agentStepTesting } from "./tools/agent-step.test-support.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; -import { testing as sessionsResolutionTesting } from "./tools/sessions-resolution.js"; +import { testing as sessionsResolutionTesting } from "./tools/sessions-resolution.test-support.js"; import { createSessionsSearchTool } from "./tools/sessions-search-tool.js"; -import { testing as sessionsSendA2ATesting } from "./tools/sessions-send-tool.a2a.js"; +import { testing as sessionsSendA2ATesting } from "./tools/sessions-send-tool.a2a.test-support.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; const TEST_CONFIG = { diff --git a/src/agents/openclaw-tools.subagents.test-harness.ts b/src/agents/openclaw-tools.subagents.test-harness.ts index 0836a738c82..87db18f2c22 100644 --- a/src/agents/openclaw-tools.subagents.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.test-harness.ts @@ -1,6 +1,6 @@ // Shared subagent tool test harness for gateway/config/queue dependency overrides. import { vi } from "vitest"; -import { testing as queueCleanupTesting } from "../auto-reply/reply/queue/cleanup.js"; +import { testing as queueCleanupTesting } from "../auto-reply/reply/queue/cleanup.test-support.js"; import type { CallGatewayOptions } from "../gateway/call.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { testing as subagentAnnounceTesting } from "./subagent-announce.js"; diff --git a/src/agents/session-suspension.test-support.ts b/src/agents/session-suspension.test-support.ts new file mode 100644 index 00000000000..0e5c8c7f181 --- /dev/null +++ b/src/agents/session-suspension.test-support.ts @@ -0,0 +1,30 @@ +import "./session-suspension.js"; + +type SessionSuspensionTestApi = { + resetSessionSuspensionStateForTest(): void; + seedClearedLaneResumeForTest( + laneId: string, + cleared: { resumeConcurrency: number; resumeAtMs: number }, + ): void; +}; + +function getTestApi(): SessionSuspensionTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.sessionSuspensionTestApi") + ]; + if (!api) { + throw new Error("session suspension test API is unavailable"); + } + return api as SessionSuspensionTestApi; +} + +export function resetSessionSuspensionStateForTest(): void { + getTestApi().resetSessionSuspensionStateForTest(); +} + +export function seedClearedLaneResumeForTest( + laneId: string, + cleared: { resumeConcurrency: number; resumeAtMs: number }, +): void { + getTestApi().seedClearedLaneResumeForTest(laneId, cleared); +} diff --git a/src/agents/session-suspension.test.ts b/src/agents/session-suspension.test.ts index ef35e83ae44..2833577e34d 100644 --- a/src/agents/session-suspension.test.ts +++ b/src/agents/session-suspension.test.ts @@ -45,8 +45,9 @@ describe("session suspension", () => { vi.clearAllTimers(); } vi.useRealTimers(); - const { testing } = await import("./session-suspension.js"); - testing.resetSessionSuspensionStateForTest(); + const { resetSessionSuspensionStateForTest } = + await import("./session-suspension.test-support.js"); + resetSessionSuspensionStateForTest(); sessionAccessorMocks.patchSessionEntry.mockClear(); commandQueueMocks.setCommandLaneConcurrency.mockClear(); }); @@ -234,11 +235,12 @@ describe("session suspension", () => { it("clamps rescheduled cleanup timers after wall-clock rollback", async () => { vi.useFakeTimers(); vi.setSystemTime(1_000); - const { enableSessionSuspensionTimersForGatewayStart, testing } = + const { enableSessionSuspensionTimersForGatewayStart } = await import("./session-suspension.js"); + const { seedClearedLaneResumeForTest } = await import("./session-suspension.test-support.js"); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const customLaneId = "plugin:voice:room-3"; - testing.seedClearedLaneResumeForTest(customLaneId, { + seedClearedLaneResumeForTest(customLaneId, { resumeConcurrency: 1, resumeAtMs: 1_000 + MAX_TIMER_TIMEOUT_MS + 1_000, }); @@ -386,12 +388,12 @@ describe("session suspension", () => { }); it("maps failover reasons to persisted suspension reasons", async () => { - const { testing } = await import("./session-suspension.js"); + const { resolveSessionSuspensionReason } = await import("./session-suspension.js"); - expect(testing.resolveSessionSuspensionReason("rate_limit")).toBe("quota_exhausted"); - expect(testing.resolveSessionSuspensionReason("billing")).toBe("manual"); - expect(testing.resolveSessionSuspensionReason("overloaded")).toBe("circuit_open"); - expect(testing.resolveSessionSuspensionReason("timeout")).toBe("circuit_open"); - expect(testing.resolveSessionSuspensionReason("auth")).toBe("circuit_open"); + expect(resolveSessionSuspensionReason("rate_limit")).toBe("quota_exhausted"); + expect(resolveSessionSuspensionReason("billing")).toBe("manual"); + expect(resolveSessionSuspensionReason("overloaded")).toBe("circuit_open"); + expect(resolveSessionSuspensionReason("timeout")).toBe("circuit_open"); + expect(resolveSessionSuspensionReason("auth")).toBe("circuit_open"); }); }); diff --git a/src/agents/session-suspension.ts b/src/agents/session-suspension.ts index 467319ef154..964a6d52819 100644 --- a/src/agents/session-suspension.ts +++ b/src/agents/session-suspension.ts @@ -413,27 +413,31 @@ async function suspendSessionQueued(params: SessionSuspensionParams, queuedGener releasePendingWrite(); } -export const testing = { - resetSessionSuspensionStateForTest: () => { - const state = getSessionSuspensionState(); - for (const entry of state.laneResumeTimers.values()) { - clearTimeout(entry.timer); - } - state.laneResumeTimers.clear(); - state.clearedLaneResumes.clear(); - state.pendingSuspensionWrites.clear(); - state.suspensionWriteChain = Promise.resolve(); - state.cleanupGeneration = 0; - state.cleanupActive = false; - }, - seedClearedLaneResumeForTest: ( - laneId: string, - cleared: { resumeConcurrency: number; resumeAtMs: number }, - ) => { - const state = getSessionSuspensionState(); - state.cleanupActive = true; - state.clearedLaneResumes.set(laneId, cleared); - }, - resolveLaneResumeConcurrency, - resolveSessionSuspensionReason, -} as const; +function resetSessionSuspensionStateForTest(): void { + const state = getSessionSuspensionState(); + for (const entry of state.laneResumeTimers.values()) { + clearTimeout(entry.timer); + } + state.laneResumeTimers.clear(); + state.clearedLaneResumes.clear(); + state.pendingSuspensionWrites.clear(); + state.suspensionWriteChain = Promise.resolve(); + state.cleanupGeneration = 0; + state.cleanupActive = false; +} + +function seedClearedLaneResumeForTest( + laneId: string, + cleared: { resumeConcurrency: number; resumeAtMs: number }, +): void { + const state = getSessionSuspensionState(); + state.cleanupActive = true; + state.clearedLaneResumes.set(laneId, cleared); +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.sessionSuspensionTestApi")] = { + resetSessionSuspensionStateForTest, + seedClearedLaneResumeForTest, + }; +} diff --git a/src/agents/sessions/tools/bash.test-support.ts b/src/agents/sessions/tools/bash.test-support.ts new file mode 100644 index 00000000000..10a886bc1c9 --- /dev/null +++ b/src/agents/sessions/tools/bash.test-support.ts @@ -0,0 +1,14 @@ +import "./bash.js"; + +type BashToolTestApi = { + resolveBashTimeoutMs(timeoutSeconds: unknown): number | undefined; +}; + +function getTestApi(): BashToolTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.bashToolTestApi") + ] as BashToolTestApi; +} + +export const resolveBashTimeoutMs: BashToolTestApi["resolveBashTimeoutMs"] = (timeoutSeconds) => + getTestApi().resolveBashTimeoutMs(timeoutSeconds); diff --git a/src/agents/sessions/tools/bash.test.ts b/src/agents/sessions/tools/bash.test.ts index ef6fbede019..78ac2520f85 100644 --- a/src/agents/sessions/tools/bash.test.ts +++ b/src/agents/sessions/tools/bash.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { describe, expect, it } from "vitest"; import type { BashOperations } from "./bash-operations.js"; -import { createBashTool, createLocalBashOperations, resolveBashTimeoutMs } from "./bash.js"; +import { createBashTool, createLocalBashOperations } from "./bash.js"; +import { resolveBashTimeoutMs } from "./bash.test-support.js"; describe("bash tool timeout helpers", () => { it("converts positive timeout seconds to timer-safe milliseconds", () => { diff --git a/src/agents/sessions/tools/bash.ts b/src/agents/sessions/tools/bash.ts index 9735d51f584..397e0c4df3c 100644 --- a/src/agents/sessions/tools/bash.ts +++ b/src/agents/sessions/tools/bash.ts @@ -28,7 +28,7 @@ const bashSchema = Type.Object({ command: Type.String({ description: "Bash command." }), timeout: Type.Optional(Type.Number({ description: "Optional timeout seconds; default none." })), }); -export function resolveBashTimeoutMs(timeoutSeconds: unknown): number | undefined { +function resolveBashTimeoutMs(timeoutSeconds: unknown): number | undefined { if ( typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || @@ -39,6 +39,12 @@ export function resolveBashTimeoutMs(timeoutSeconds: unknown): number | undefine return resolveTimerTimeoutMs(timeoutSeconds * 1000, 1); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.bashToolTestApi")] = { + resolveBashTimeoutMs, + }; +} + /** * Create bash operations using OpenClaw runtime's built-in local shell execution backend. * diff --git a/src/agents/tool-search.stream-errors.test.ts b/src/agents/tool-search.stream-errors.test.ts index 8abe636bdff..1ea3581792e 100644 --- a/src/agents/tool-search.stream-errors.test.ts +++ b/src/agents/tool-search.stream-errors.test.ts @@ -40,20 +40,22 @@ vi.mock("node:child_process", async () => { const spawnMock = vi.mocked(spawn); let toolSearch: typeof import("./tool-search.js"); +let testing: (typeof import("./tool-search.test-support.js"))["testing"]; describe("tool-search code-mode stream errors", () => { beforeAll(async () => { toolSearch = await import("./tool-search.js"); + testing = (await import("./tool-search.test-support.js")).testing; }); afterEach(() => { - toolSearch.testing.setToolSearchCodeModeSupportedForTest(undefined); - toolSearch.testing.setToolSearchMinCodeTimeoutMsForTest(undefined); + testing.setToolSearchCodeModeSupportedForTest(undefined); + testing.setToolSearchMinCodeTimeoutMsForTest(undefined); }); it("rejects stderr errors and leaves the unused stdout unpiped", async () => { - toolSearch.testing.setToolSearchCodeModeSupportedForTest(true); - toolSearch.testing.setToolSearchMinCodeTimeoutMsForTest(1000); + testing.setToolSearchCodeModeSupportedForTest(true); + testing.setToolSearchMinCodeTimeoutMsForTest(1000); let spawnedChild: MockSpawnChild | undefined; spawnMock.mockImplementationOnce( @@ -67,15 +69,12 @@ describe("tool-search code-mode stream errors", () => { }, ); - const runtime = new toolSearch.ToolSearchRuntime( - {}, - toolSearch.testing.resolveToolSearchConfig({}), - ); + const runtime = new toolSearch.ToolSearchRuntime({}, toolSearch.resolveToolSearchConfig({})); await expect( - toolSearch.testing.runCodeModeChild({ + testing.runCodeModeChild({ code: "return 1;", - config: toolSearch.testing.resolveToolSearchConfig({}), + config: toolSearch.resolveToolSearchConfig({}), logs: [], parentToolCallId: "call-stderr-error", runtime, @@ -89,8 +88,8 @@ describe("tool-search code-mode stream errors", () => { }); it("keeps stderr tail in exit error messages valid at UTF-16 boundaries", async () => { - toolSearch.testing.setToolSearchCodeModeSupportedForTest(true); - toolSearch.testing.setToolSearchMinCodeTimeoutMsForTest(1000); + testing.setToolSearchCodeModeSupportedForTest(true); + testing.setToolSearchMinCodeTimeoutMsForTest(1000); spawnMock.mockImplementationOnce( (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { @@ -105,16 +104,13 @@ describe("tool-search code-mode stream errors", () => { }, ); - const runtime = new toolSearch.ToolSearchRuntime( - {}, - toolSearch.testing.resolveToolSearchConfig({}), - ); + const runtime = new toolSearch.ToolSearchRuntime({}, toolSearch.resolveToolSearchConfig({})); let caught: Error | undefined; try { - await toolSearch.testing.runCodeModeChild({ + await testing.runCodeModeChild({ code: "return 1;", - config: toolSearch.testing.resolveToolSearchConfig({}), + config: toolSearch.resolveToolSearchConfig({}), logs: [], parentToolCallId: "call-stderr-utf16", runtime, diff --git a/src/agents/tool-search.test-support.ts b/src/agents/tool-search.test-support.ts new file mode 100644 index 00000000000..3fe9efdd376 --- /dev/null +++ b/src/agents/tool-search.test-support.ts @@ -0,0 +1,47 @@ +import type { ToolSearchCatalogEntry, ToolSearchConfig, ToolSearchRuntime } from "./tool-search.js"; +import "./tool-search.js"; + +type ToolSearchCatalogSession = { + entries: ToolSearchCatalogEntry[]; + searchCount: number; + describeCount: number; + callCount: number; +}; + +type ToolSearchTestApi = { + sessionCatalogs: Map; + maxToolSchemaDirectoryPromptChars: number; + setToolSearchCodeModeSupportedForTest(value: boolean | undefined): void; + setToolSearchMinCodeTimeoutMsForTest(value: number | undefined): void; + appendToolSearchCodeStderrTail(current: string, chunk: string): string; + runCodeModeChild(params: { + code: string; + config: ToolSearchConfig; + logs: unknown[]; + parentToolCallId: string; + runtime: ToolSearchRuntime; + signal?: AbortSignal; + }): Promise; +}; + +function getTestApi(): ToolSearchTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.toolSearchTestApi") + ] as ToolSearchTestApi; +} + +export const testing: ToolSearchTestApi = { + get sessionCatalogs() { + return getTestApi().sessionCatalogs; + }, + get maxToolSchemaDirectoryPromptChars() { + return getTestApi().maxToolSchemaDirectoryPromptChars; + }, + setToolSearchCodeModeSupportedForTest: (value) => + getTestApi().setToolSearchCodeModeSupportedForTest(value), + setToolSearchMinCodeTimeoutMsForTest: (value) => + getTestApi().setToolSearchMinCodeTimeoutMsForTest(value), + appendToolSearchCodeStderrTail: (current, chunk) => + getTestApi().appendToolSearchCodeStderrTail(current, chunk), + runCodeModeChild: (params) => getTestApi().runCodeModeChild(params), +}; diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index d80b9892626..b769c4d6743 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -11,7 +11,6 @@ import { } from "./agent-tools.before-tool-call.js"; import { SESSION_TOOL_STDERR_TAIL_BYTES } from "./sessions/tools/limits.js"; import { - testing, addClientToolsToToolSearchCatalog, applyToolSearchCatalog, applyToolSchemaDirectoryCatalog, @@ -22,12 +21,14 @@ import { estimateToolSchemaDirectoryToolNames, projectToolSearchTargetTranscriptMessages, registerHeadlessToolSearchCatalog, + resolveToolSearchConfig, resolveToolSearchCatalogTool, TOOL_CALL_RAW_TOOL_NAME, TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_CODE_MODE_TOOL_NAME, TOOL_SEARCH_RAW_TOOL_NAME, } from "./tool-search.js"; +import { testing } from "./tool-search.test-support.js"; import { jsonResult, type AnyAgentTool } from "./tools/common.js"; function fakeTool(name: string, description: string): AnyAgentTool { @@ -171,7 +172,7 @@ describe("Tool Search", () => { }); it("enables object config when a mode is set", () => { - const resolved = testing.resolveToolSearchConfig({ + const resolved = resolveToolSearchConfig({ tools: { toolSearch: { mode: "directory", @@ -186,7 +187,7 @@ describe("Tool Search", () => { testing.setToolSearchCodeModeSupportedForTest(false); try { const config = { tools: { toolSearch: true } } as never; - const resolved = testing.resolveToolSearchConfig(config); + const resolved = resolveToolSearchConfig(config); const compacted = applyToolSearchCatalog({ tools: [ fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"), diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index a3b6b9bd4d7..465d6e86e44 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -143,7 +143,7 @@ type ToolSearchDirectoryIntent = { type ToolDirectoryFamily = "memory" | "web"; -export type ToolSearchCatalogSession = { +type ToolSearchCatalogSession = { entries: ToolSearchCatalogEntry[]; searchCount: number; describeCount: number; @@ -2393,7 +2393,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ ]; } -export const testing = { +const testing = { sessionCatalogs, reusableCatalogSnapshots, maxToolSchemaDirectoryPromptChars: MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS, @@ -2413,4 +2413,8 @@ export const testing = { appendToolSearchCodeStderrTail, runCodeModeChild, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.toolSearchTestApi")] = testing; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/tools/image-generate-tool.test-support.ts b/src/agents/tools/image-generate-tool.test-support.ts new file mode 100644 index 00000000000..079312ebc99 --- /dev/null +++ b/src/agents/tools/image-generate-tool.test-support.ts @@ -0,0 +1,22 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import type { ToolModelConfig } from "./model-config.helpers.js"; +import "./image-generate-tool.js"; + +type ImageGenerateToolTestApi = { + resolveImageGenerationModelConfigForTool(params: { + cfg?: OpenClawConfig; + workspaceDir?: string; + agentDir?: string; + authStore?: AuthProfileStore; + }): ToolModelConfig | null; +}; + +function getTestApi(): ImageGenerateToolTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.imageGenerateToolTestApi") + ] as ImageGenerateToolTestApi; +} + +export const resolveImageGenerationModelConfigForTool: ImageGenerateToolTestApi["resolveImageGenerationModelConfigForTool"] = + (params) => getTestApi().resolveImageGenerationModelConfigForTool(params); diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index acf655bda1d..acf79816449 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -33,7 +33,7 @@ let mediaStore: typeof import("../../media/store.js"); let webMedia: typeof import("../../media/web-media.js"); let resetRecentMediaGenerationDuplicateGuardsForTests: typeof import("../media-generation-task-status-shared.test-support.js").resetRecentMediaGenerationDuplicateGuardsForTests; let createImageGenerateTool: typeof import("./image-generate-tool.js").createImageGenerateTool; -let resolveImageGenerationModelConfigForTool: typeof import("./image-generate-tool.js").resolveImageGenerationModelConfigForTool; +let resolveImageGenerationModelConfigForTool: typeof import("./image-generate-tool.test-support.js").resolveImageGenerationModelConfigForTool; const GENERATION_PROVIDER_ENV_VARS = [ "BYTEPLUS_API_KEY", @@ -337,8 +337,9 @@ describe("createImageGenerateTool", () => { webMedia = await import("../../media/web-media.js"); ({ resetRecentMediaGenerationDuplicateGuardsForTests } = await import("../media-generation-task-status-shared.test-support.js")); - ({ createImageGenerateTool, resolveImageGenerationModelConfigForTool } = - await import("./image-generate-tool.js")); + ({ createImageGenerateTool } = await import("./image-generate-tool.js")); + ({ resolveImageGenerationModelConfigForTool } = + await import("./image-generate-tool.test-support.js")); }); beforeEach(() => { diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 2d651c76077..a35badd3cad 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -245,7 +245,7 @@ const ImageGenerateToolSchema = Type.Object({ ), }); -export function resolveImageGenerationModelConfigForTool(params: { +function resolveImageGenerationModelConfigForTool(params: { cfg?: OpenClawConfig; workspaceDir?: string; agentDir?: string; @@ -261,6 +261,12 @@ export function resolveImageGenerationModelConfigForTool(params: { }); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.imageGenerateToolTestApi")] = { + resolveImageGenerationModelConfigForTool, + }; +} + function hasExplicitImageGenerationModelConfig(cfg?: OpenClawConfig): boolean { return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.imageGenerationModel)); } diff --git a/src/agents/tools/image-tool.custom-provider-auth.regression.test.ts b/src/agents/tools/image-tool.custom-provider-auth.regression.test.ts index 695a9875e5a..e22c8baf65a 100644 --- a/src/agents/tools/image-tool.custom-provider-auth.regression.test.ts +++ b/src/agents/tools/image-tool.custom-provider-auth.regression.test.ts @@ -9,7 +9,8 @@ import type { ModelDefinitionConfig } from "../../config/types.models.js"; import type { ImageDescriptionRequest } from "../../plugin-sdk/media-understanding.js"; import { getApiKeyForModel, hasUsableCustomProviderApiKey } from "../model-auth.js"; import { resolveImageToolFactoryAvailable } from "../openclaw-tools.media-factory-plan.js"; -import { createImageTool, resolveImageModelConfigForTool, testing } from "./image-tool.js"; +import { createImageTool } from "./image-tool.js"; +import { resolveImageModelConfigForTool, testing } from "./image-tool.test-support.js"; import { hasProviderAuthForTool } from "./model-config.helpers.js"; const USER_PROVIDER = "hatchery-qwen3.6-plus"; diff --git a/src/agents/tools/image-tool.providers.live.test.ts b/src/agents/tools/image-tool.providers.live.test.ts index 51fe540ece8..b7026a4a65c 100644 --- a/src/agents/tools/image-tool.providers.live.test.ts +++ b/src/agents/tools/image-tool.providers.live.test.ts @@ -21,7 +21,8 @@ import { } from "../../plugin-sdk/test-env.js"; import { isLiveTestEnabled } from "../live-test-helpers.js"; import { isLiveAuthDrift } from "../live-test-provider-drift.js"; -import { createImageTool, testing } from "./image-tool.js"; +import { createImageTool } from "./image-tool.js"; +import { testing } from "./image-tool.test-support.js"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY?.trim() ?? ""; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; diff --git a/src/agents/tools/image-tool.test-support.ts b/src/agents/tools/image-tool.test-support.ts new file mode 100644 index 00000000000..2c3b3b681f0 --- /dev/null +++ b/src/agents/tools/image-tool.test-support.ts @@ -0,0 +1,96 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { + resolveAutoMediaKeyProviders, + resolveDefaultMediaModel, +} from "../../media-understanding/defaults.js"; +import type { + buildMediaUnderstandingRegistry, + getMediaUnderstandingProvider, +} from "../../media-understanding/provider-registry.js"; +import type { ImageCompressionPolicy, WebMediaResult } from "../../media/web-media.js"; +import type { + describeImageWithModel, + describeImagesWithModel, +} from "../../plugin-sdk/media-understanding.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import type { resolveBundledStaticCatalogModel } from "../embedded-agent-runner/model.static-catalog.js"; +import type { + coerceImageAssistantText, + decodeDataUrl, + hasImageReasoningOnlyResponse, + ImageModelConfig, +} from "./image-tool.helpers.js"; +import "./image-tool.js"; + +type ResolveModelAsync = (typeof import("../embedded-agent-runner/model.js"))["resolveModelAsync"]; + +type ImageToolLoadWebMediaOptions = { + maxBytes?: number; + sandboxValidated?: boolean; + readFile?: (filePath: string) => Promise; + imageCompression?: ImageCompressionPolicy; + localRoots?: readonly string[] | "any"; + inboundRoots?: readonly string[]; + ssrfPolicy?: ReturnType< + (typeof import("./media-tool-shared.js"))["resolveRemoteMediaSsrfPolicy"] + >; + readIdleTimeoutMs?: number; +}; + +type ImageWebMediaRuntime = { + loadWebMedia(mediaUrl: string, options?: ImageToolLoadWebMediaOptions): Promise; + optimizeImageBufferForWebMedia: (typeof import("../../media/web-media.js"))["optimizeImageBufferForWebMedia"]; +}; + +type ResolveImageCompressionPolicy = (params: { + cfg?: OpenClawConfig; + imageModelConfig?: ImageModelConfig | null; + modelOverride?: string; + imageCount: number; + agentDir?: string; + workspaceDir?: string; +}) => Promise; + +type ImageToolProviderDeps = { + buildProviderRegistry: typeof buildMediaUnderstandingRegistry; + getMediaUnderstandingProvider: typeof getMediaUnderstandingProvider; + describeImageWithModel: typeof describeImageWithModel; + describeImagesWithModel: typeof describeImagesWithModel; + resolveAutoMediaKeyProviders: typeof resolveAutoMediaKeyProviders; + resolveDefaultMediaModel: typeof resolveDefaultMediaModel; + resolveBundledStaticCatalogModel: typeof resolveBundledStaticCatalogModel; + resolveModelAsync: ResolveModelAsync; + resolveImageCompressionPolicy: ResolveImageCompressionPolicy; + loadImageWebMediaRuntime: () => Promise; +}; + +type ImageToolTestApi = { + decodeDataUrl: typeof decodeDataUrl; + coerceImageAssistantText: typeof coerceImageAssistantText; + hasImageReasoningOnlyResponse: typeof hasImageReasoningOnlyResponse; + resolveImageToolMaxTokens( + modelMaxTokens: number | undefined, + requestedMaxTokens?: number, + ): number; + resolveImageCompressionPolicy: ResolveImageCompressionPolicy; + setProviderDepsForTest(overrides?: Partial): void; + resolveImageModelConfigForTool(params: { + cfg?: OpenClawConfig; + agentDir: string; + workspaceDir?: string; + authStore?: AuthProfileStore; + }): ImageModelConfig | null; +}; + +function getTestApi(): ImageToolTestApi { + const api = (globalThis as Record)[Symbol.for("openclaw.imageToolTestApi")]; + if (!api) { + throw new Error("image tool test API is unavailable"); + } + return api as ImageToolTestApi; +} + +export const testing = getTestApi(); +export const resolveImageModelConfigForTool: ImageToolTestApi["resolveImageModelConfigForTool"] = ( + params, +) => testing.resolveImageModelConfigForTool(params); diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index ee76927096c..6caa720d7f1 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -22,7 +22,8 @@ import { minimaxUnderstandImage } from "../minimax-vlm.js"; import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js"; import { createUnsafeMountedSandbox } from "../test-helpers/unsafe-mounted-sandbox.js"; import { makeZeroUsageSnapshot } from "../usage.js"; -import { testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js"; +import { createImageTool } from "./image-tool.js"; +import { testing, resolveImageModelConfigForTool } from "./image-tool.test-support.js"; import { resolveMediaToolInboundRoots } from "./media-tool-shared.js"; function jsonRoundTrip(value: T): T { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index d2ee07670c3..cc7e1ee0d11 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -180,7 +180,7 @@ function isCanonicalCandidateShadowedByExecutionAlias( ); } -export const testing = { +const testing = { decodeDataUrl, coerceImageAssistantText, hasImageReasoningOnlyResponse, @@ -240,7 +240,7 @@ function resolveImageToolMaxTokens(modelMaxTokens: number | undefined, requested * - same provider (best effort) * - fall back to OpenAI/Anthropic when available */ -export function resolveImageModelConfigForTool(params: { +function resolveImageModelConfigForTool(params: { cfg?: OpenClawConfig; agentDir: string; workspaceDir?: string; @@ -366,6 +366,13 @@ export function resolveImageModelConfigForTool(params: { }); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.imageToolTestApi")] = { + ...testing, + resolveImageModelConfigForTool, + }; +} + function resolveImageModelConfigForOverride(params: { cfg?: OpenClawConfig; modelOverride?: string; diff --git a/src/agents/tools/model-config.helpers.test-support.ts b/src/agents/tools/model-config.helpers.test-support.ts new file mode 100644 index 00000000000..f20e165022d --- /dev/null +++ b/src/agents/tools/model-config.helpers.test-support.ts @@ -0,0 +1,23 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import "./model-config.helpers.js"; + +type ModelConfigHelpersTestApi = { + hasDirectProviderApiKeyAuthForTool(params: { + provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; + agentDir?: string; + authStore?: AuthProfileStore; + modelApi?: string; + }): boolean; +}; + +function getTestApi(): ModelConfigHelpersTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.modelConfigHelpersTestApi") + ] as ModelConfigHelpersTestApi; +} + +export const hasDirectProviderApiKeyAuthForTool: ModelConfigHelpersTestApi["hasDirectProviderApiKeyAuthForTool"] = + (params) => getTestApi().hasDirectProviderApiKeyAuthForTool(params); diff --git a/src/agents/tools/model-config.helpers.test.ts b/src/agents/tools/model-config.helpers.test.ts index f0b4dde48cd..dcece4ff400 100644 --- a/src/agents/tools/model-config.helpers.test.ts +++ b/src/agents/tools/model-config.helpers.test.ts @@ -4,10 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; import { - hasDirectProviderApiKeyAuthForTool, hasProviderAuthForTool, resolveOpenAiImageMediaCandidate, } from "./model-config.helpers.js"; +import { hasDirectProviderApiKeyAuthForTool } from "./model-config.helpers.test-support.js"; vi.mock("../auth-profiles/external-cli-sync.js", () => ({ resolveExternalCliAuthProfiles: () => [], diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index dbaf8ed1c45..33e6cce7e91 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -240,7 +240,7 @@ function hasAuthProfileTypeForProvider(params: { } /** Returns whether a provider has direct API-key-capable auth for model-backed tools. */ -export function hasDirectProviderApiKeyAuthForTool(params: { +function hasDirectProviderApiKeyAuthForTool(params: { provider: string; cfg?: OpenClawConfig; workspaceDir?: string; @@ -272,6 +272,12 @@ export function hasDirectProviderApiKeyAuthForTool(params: { }); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.modelConfigHelpersTestApi")] = { + hasDirectProviderApiKeyAuthForTool, + }; +} + function hasCanonicalOpenAiCodexAuthSignal(params: { cfg?: OpenClawConfig; agentDir?: string; diff --git a/src/agents/tools/sessions-access.test.ts b/src/agents/tools/sessions-access.test.ts index 2d62040efff..93ed360ecf0 100644 --- a/src/agents/tools/sessions-access.test.ts +++ b/src/agents/tools/sessions-access.test.ts @@ -11,7 +11,7 @@ import { resolveSessionToolsVisibility, } from "../../plugin-sdk/session-visibility.js"; import { resolveSandboxedSessionToolContext } from "./sessions-access.js"; -import { testing as sessionsResolutionTesting } from "./sessions-resolution.js"; +import { testing as sessionsResolutionTesting } from "./sessions-resolution.test-support.js"; describe("resolveSessionToolsVisibility", () => { it("defaults to tree when unset or invalid", () => { diff --git a/src/agents/tools/sessions-resolution.test-support.ts b/src/agents/tools/sessions-resolution.test-support.ts new file mode 100644 index 00000000000..9332a692774 --- /dev/null +++ b/src/agents/tools/sessions-resolution.test-support.ts @@ -0,0 +1,16 @@ +import type { callGateway } from "../../gateway/call.js"; +import "./sessions-resolution.js"; + +type SessionsResolutionTestApi = { + testing: { + setDepsForTest(overrides?: Partial<{ callGateway: typeof callGateway }>): void; + }; +}; + +function getTestApi(): SessionsResolutionTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.sessionsResolutionTestApi") + ] as SessionsResolutionTestApi; +} + +export const testing = getTestApi().testing; diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index 724288ca1e6..fdc8fad500f 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -471,7 +471,7 @@ export async function resolveVisibleSessionReference(params: { return { ok: true, key: resolvedKey, displayKey }; } -export const testing = { +const testing = { setDepsForTest(overrides?: Partial<{ callGateway: GatewayCaller }>) { sessionsResolutionDeps = overrides ? { @@ -484,3 +484,9 @@ export const testing = { ); }, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.sessionsResolutionTestApi")] = { + testing, + }; +} diff --git a/src/agents/tools/sessions-send-tool.a2a.test-support.ts b/src/agents/tools/sessions-send-tool.a2a.test-support.ts new file mode 100644 index 00000000000..c86d2eafe79 --- /dev/null +++ b/src/agents/tools/sessions-send-tool.a2a.test-support.ts @@ -0,0 +1,18 @@ +import type { CallGatewayOptions } from "../../gateway/call.js"; +import "./sessions-send-tool.a2a.js"; + +type GatewayCaller = (opts: CallGatewayOptions) => Promise; + +type SessionsSendA2ATestApi = { + testing: { + setDepsForTest(overrides?: Partial<{ callGateway: GatewayCaller }>): void; + }; +}; + +function getTestApi(): SessionsSendA2ATestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.sessionsSendA2ATestApi") + ] as SessionsSendA2ATestApi; +} + +export const testing = getTestApi().testing; diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index 3dbdaed4526..7d9fc205d4b 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -7,7 +7,8 @@ import { createSessionConversationTestRegistry } from "../../test-utils/session- import { readLatestAssistantReplySnapshot, waitForAgentRun } from "../run-wait.js"; import { runAgentStep } from "./agent-step.js"; import type { SessionListRow } from "./sessions-helpers.js"; -import { runSessionsSendA2AFlow, testing } from "./sessions-send-tool.a2a.js"; +import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js"; +import { testing } from "./sessions-send-tool.a2a.test-support.js"; const callGatewayMock = vi.hoisted(() => vi.fn()); diff --git a/src/agents/tools/sessions-send-tool.a2a.ts b/src/agents/tools/sessions-send-tool.a2a.ts index 8fb2d912c87..c7079bb8415 100644 --- a/src/agents/tools/sessions-send-tool.a2a.ts +++ b/src/agents/tools/sessions-send-tool.a2a.ts @@ -258,7 +258,7 @@ export async function runSessionsSendA2AFlow(params: { } } -export const testing = { +const testing = { setDepsForTest(overrides?: Partial<{ callGateway: GatewayCaller }>) { sessionsSendA2ADeps = overrides ? { @@ -268,3 +268,9 @@ export const testing = { : defaultSessionsSendA2ADeps; }, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.sessionsSendA2ATestApi")] = { + testing, + }; +} diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index 6073b3113d7..ca0209b92f8 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -11,7 +11,7 @@ import { isAgentSessionModelPatchOrigin } from "../../gateway/session-model-patc import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; import { createAgentPatchedSessionModelRunGuard } from "../session-model-auto-revert.js"; -import { testing as sessionsResolutionTesting } from "./sessions-resolution.js"; +import { testing as sessionsResolutionTesting } from "./sessions-resolution.test-support.js"; import { createSessionsTool } from "./sessions-tool.js"; describe("sessions tool", () => { diff --git a/src/agents/tools/video-generate-tool.test-support.ts b/src/agents/tools/video-generate-tool.test-support.ts new file mode 100644 index 00000000000..269f97192e9 --- /dev/null +++ b/src/agents/tools/video-generate-tool.test-support.ts @@ -0,0 +1,22 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import type { ToolModelConfig } from "./model-config.helpers.js"; +import "./video-generate-tool.js"; + +type VideoGenerateToolTestApi = { + resolveVideoGenerationModelConfigForTool(params: { + cfg?: OpenClawConfig; + workspaceDir?: string; + agentDir?: string; + authStore?: AuthProfileStore; + }): ToolModelConfig | null; +}; + +function getTestApi(): VideoGenerateToolTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.videoGenerateToolTestApi") + ] as VideoGenerateToolTestApi; +} + +export const resolveVideoGenerationModelConfigForTool: VideoGenerateToolTestApi["resolveVideoGenerationModelConfigForTool"] = + (params) => getTestApi().resolveVideoGenerationModelConfigForTool(params); diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index d94c9f8c37b..a15b081e5da 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -17,10 +17,8 @@ import * as videoGenerationRuntime from "../../video-generation/runtime.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { resetRecentMediaGenerationDuplicateGuardsForTests } from "../media-generation-task-status-shared.test-support.js"; import * as videoGenerateBackground from "./video-generate-background.js"; -import { - createVideoGenerateTool, - resolveVideoGenerationModelConfigForTool, -} from "./video-generate-tool.js"; +import { createVideoGenerateTool } from "./video-generate-tool.js"; +import { resolveVideoGenerationModelConfigForTool } from "./video-generate-tool.test-support.js"; const taskRuntimeInternalMocks = vi.hoisted(() => { const mocks = { diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index d83784544f1..ce5655b8ca6 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -227,7 +227,7 @@ function createVideoGenerateToolSchema(params: { includeAudioReferences: boolean return Type.Object(properties); } -export function resolveVideoGenerationModelConfigForTool(params: { +function resolveVideoGenerationModelConfigForTool(params: { cfg?: OpenClawConfig; workspaceDir?: string; agentDir?: string; @@ -243,6 +243,12 @@ export function resolveVideoGenerationModelConfigForTool(params: { }); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.videoGenerateToolTestApi")] = { + resolveVideoGenerationModelConfigForTool, + }; +} + function hasExplicitVideoGenerationModelConfig(cfg?: OpenClawConfig): boolean { return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.videoGenerationModel)); } diff --git a/src/agents/tools/web-fetch.test-support.ts b/src/agents/tools/web-fetch.test-support.ts new file mode 100644 index 00000000000..b771aa8a048 --- /dev/null +++ b/src/agents/tools/web-fetch.test-support.ts @@ -0,0 +1,14 @@ +import "./web-fetch.js"; + +type WebFetchTestApi = { + sanitizeWebFetchUrl(raw: string): string; +}; + +function getTestApi(): WebFetchTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.webFetchTestApi") + ] as WebFetchTestApi; +} + +export const sanitizeWebFetchUrl: WebFetchTestApi["sanitizeWebFetchUrl"] = (raw) => + getTestApi().sanitizeWebFetchUrl(raw); diff --git a/src/agents/tools/web-fetch.test.ts b/src/agents/tools/web-fetch.test.ts index d15fac23630..045bb3c2857 100644 --- a/src/agents/tools/web-fetch.test.ts +++ b/src/agents/tools/web-fetch.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { getToolTerminalPresentation } from "../tool-terminal-presentation.js"; -import { createWebFetchTool, sanitizeWebFetchUrl } from "./web-fetch.js"; +import { createWebFetchTool } from "./web-fetch.js"; +import { sanitizeWebFetchUrl } from "./web-fetch.test-support.js"; describe("sanitizeWebFetchUrl", () => { it("removes whitespace between scheme and authority (reported bug)", () => { diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 843446cbcd9..f21b255afe9 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -433,7 +433,7 @@ function throwIfFetchAborted(signal: AbortSignal | undefined): void { * throw. Path and query whitespace is intentionally preserved — the WHATWG URL * parser percent-encodes those characters correctly per RFC 3986. */ -export function sanitizeWebFetchUrl(raw: string): string { +function sanitizeWebFetchUrl(raw: string): string { let end = raw.length; while (end > 0 && raw.charCodeAt(end - 1) <= 0x20) { end -= 1; @@ -443,6 +443,12 @@ export function sanitizeWebFetchUrl(raw: string): string { return repaired.replace(/^(https?:\/\/[^/?#\s]+)\s+$/i, "$1"); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.webFetchTestApi")] = { + sanitizeWebFetchUrl, + }; +} + async function normalizeProviderWebFetchPayload(params: { providerId: string; payload: unknown; diff --git a/src/agents/transport-params-runtime-contract.test.ts b/src/agents/transport-params-runtime-contract.test.ts index a35a943f777..58b0e19dece 100644 --- a/src/agents/transport-params-runtime-contract.test.ts +++ b/src/agents/transport-params-runtime-contract.test.ts @@ -12,11 +12,11 @@ import { } from "../../test/helpers/agents/transport-params-runtime-contract.js"; import { createOpenAIThinkingLevelWrapper } from "../llm/providers/stream-wrappers/openai.js"; import { - testing as extraParamsTesting, applyExtraParamsToAgent, resolveExtraParams, resolvePreparedExtraParams, } from "./embedded-agent-runner/extra-params.js"; +import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.test-support.js"; import { supportsGptParallelToolCallsPayload } from "./provider-api-families.js"; beforeEach(() => { diff --git a/src/agents/utils/tools-manager.test-support.ts b/src/agents/utils/tools-manager.test-support.ts new file mode 100644 index 00000000000..c9e5e266fa6 --- /dev/null +++ b/src/agents/utils/tools-manager.test-support.ts @@ -0,0 +1,15 @@ +import "./tools-manager.js"; + +type ToolsManagerTestApi = { + testing: { + downloadFile(url: string, dest: string, maxBytes: number): Promise; + }; +}; + +function getTestApi(): ToolsManagerTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.toolsManagerTestApi") + ] as ToolsManagerTestApi; +} + +export const testing = getTestApi().testing; diff --git a/src/agents/utils/tools-manager.test.ts b/src/agents/utils/tools-manager.test.ts index e94b709f116..e9976af3fe5 100644 --- a/src/agents/utils/tools-manager.test.ts +++ b/src/agents/utils/tools-manager.test.ts @@ -150,7 +150,7 @@ describe("ensureTool", () => { finalUrl: "https://example.com/archive.tar.gz", }); const destination = join(tempAgentDir!, "archive.tar.gz"); - const { testing } = await import("./tools-manager.js"); + const { testing } = await import("./tools-manager.test-support.js"); await expect( testing.downloadFile("https://example.com/archive.tar.gz", destination, 10), @@ -179,7 +179,7 @@ describe("ensureTool", () => { finalUrl: "https://example.com/archive.tar.gz", }); const destination = join(tempAgentDir!, "archive.tar.gz"); - const { testing } = await import("./tools-manager.js"); + const { testing } = await import("./tools-manager.test-support.js"); await expect( testing.downloadFile("https://example.com/archive.tar.gz", destination, 10), @@ -201,7 +201,7 @@ describe("ensureTool", () => { finalUrl: "https://example.com/archive.tar.gz", }); const destination = join(tempAgentDir!, "archive.tar.gz"); - const { testing } = await import("./tools-manager.js"); + const { testing } = await import("./tools-manager.test-support.js"); await testing.downloadFile("https://example.com/archive.tar.gz", destination, body.byteLength); diff --git a/src/agents/utils/tools-manager.ts b/src/agents/utils/tools-manager.ts index 858ced3e3c9..b53add1cac3 100644 --- a/src/agents/utils/tools-manager.ts +++ b/src/agents/utils/tools-manager.ts @@ -417,6 +417,12 @@ export async function ensureTool(tool: "fd" | "rg", silent = false): Promise)[Symbol.for("openclaw.toolsManagerTestApi")] = { + testing, + }; +} diff --git a/src/auto-reply/reply/abort.test-support.ts b/src/auto-reply/reply/abort.test-support.ts new file mode 100644 index 00000000000..0842cd2b125 --- /dev/null +++ b/src/auto-reply/reply/abort.test-support.ts @@ -0,0 +1,34 @@ +import "./abort.js"; + +type AbortTestDeps = { + getAcpSessionManager: typeof import("../../acp/control-plane/manager.js").getAcpSessionManager; + abortEmbeddedAgentRun: typeof import("../../agents/embedded-agent-runner/runs.js").abortEmbeddedAgentRun; + resolveActiveEmbeddedRunSessionId: typeof import("../../agents/embedded-agent-runner/runs.js").resolveActiveEmbeddedRunSessionId; + markSessionAbortTarget: typeof import("../../config/sessions/session-accessor.js").markSessionAbortTarget; + resolveSessionAbortTarget: typeof import("../../config/sessions/session-accessor.js").resolveSessionAbortTarget; + getLatestSubagentRunByChildSessionKey: typeof import("../../agents/subagent-registry.js").getLatestSubagentRunByChildSessionKey; + listSubagentRunsForController: typeof import("../../agents/subagent-registry.js").listSubagentRunsForController; + markSubagentRunTerminated: typeof import("../../agents/subagent-registry.js").markSubagentRunTerminated; +}; + +type AbortTestApi = { + setDepsForTests(deps: Partial | undefined): void; + resetDepsForTests(): void; +}; + +function getTestApi(): AbortTestApi { + const api = (globalThis as Record)[Symbol.for("openclaw.abortTestApi")]; + if (!api) { + throw new Error("abort test API is unavailable"); + } + return api as AbortTestApi; +} + +export const testing = { + setDepsForTests(deps: Partial | undefined): void { + getTestApi().setDepsForTests(deps); + }, + resetDepsForTests(): void { + getTestApi().resetDepsForTests(); + }, +}; diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 5a2a98f39ae..3c7abaf9610 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -13,7 +13,6 @@ import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js"; import { resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff } from "./abort-cutoff.js"; import { getAbortMemory } from "./abort-primitives.js"; import { - testing as abortTesting, formatAbortReplyText, isAbortRequestText, isAbortTrigger, @@ -21,14 +20,12 @@ import { stopSubagentsForRequester, tryFastAbortFromMessage, } from "./abort.js"; -import { testing as acpResetTargetTesting } from "./acp-reset-target.js"; +import { testing as abortTesting } from "./abort.test-support.js"; +import { testing as acpResetTargetTesting } from "./acp-reset-target.test-support.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./queue.js"; -import { testing as queueCleanupTesting } from "./queue/cleanup.js"; -import { - createReplyOperation, - replyRunRegistry, - testing as replyRunRegistryTesting, -} from "./reply-run-registry.js"; +import { testing as queueCleanupTesting } from "./queue/cleanup.test-support.js"; +import { createReplyOperation, replyRunRegistry } from "./reply-run-registry.js"; +import { testing as replyRunRegistryTesting } from "./reply-run-registry.test-support.js"; import { buildTestCtx } from "./test-ctx.js"; vi.mock("../../agents/embedded-agent.js", () => ({ diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 734961c6ac5..1f3e22c62d1 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -63,7 +63,7 @@ const abortDeps = { ...defaultAbortDeps, }; -export const testing = { +const abortTestApi = { setDepsForTests(deps: Partial | undefined): void { abortDeps.getAcpSessionManager = deps?.getAcpSessionManager ?? defaultAbortDeps.getAcpSessionManager; @@ -97,6 +97,10 @@ export const testing = { }, }; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.abortTestApi")] = abortTestApi; +} + export function abortSessionRunTargetWithOutcome(params: { key?: string; sessionId?: string }): { active: boolean; aborted: boolean; diff --git a/src/auto-reply/reply/acp-reset-target.test-support.ts b/src/auto-reply/reply/acp-reset-target.test-support.ts new file mode 100644 index 00000000000..13bd155bc49 --- /dev/null +++ b/src/auto-reply/reply/acp-reset-target.test-support.ts @@ -0,0 +1,30 @@ +import type { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js"; +import type { listAcpBindings } from "../../config/bindings.js"; +import type { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; +import "./acp-reset-target.js"; + +type AcpResetTargetTestDeps = { + getSessionBindingService: typeof getSessionBindingService; + listAcpBindings: typeof listAcpBindings; + resolveConfiguredBindingRecord: typeof resolveConfiguredBindingRecord; +}; + +type AcpResetTargetTestApi = { + setDepsForTest(overrides?: Partial): void; +}; + +function getTestApi(): AcpResetTargetTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.acpResetTargetTestApi") + ]; + if (!api) { + throw new Error("ACP reset target test API is unavailable"); + } + return api as AcpResetTargetTestApi; +} + +export const testing = { + setDepsForTest(overrides?: Partial): void { + getTestApi().setDepsForTest(overrides); + }, +}; diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts index 9c43b8bda51..85cc269153c 100644 --- a/src/auto-reply/reply/acp-reset-target.ts +++ b/src/auto-reply/reply/acp-reset-target.ts @@ -20,7 +20,7 @@ const acpResetTargetDeps = { resolveConfiguredBindingRecord, }; -export const testing = { +const acpResetTargetTestApi = { setDepsForTest( overrides?: Partial<{ getSessionBindingService: typeof getSessionBindingService; @@ -36,6 +36,11 @@ export const testing = { }, }; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.acpResetTargetTestApi")] = + acpResetTargetTestApi; +} + function resolveResetTargetAccountId(params: { cfg: OpenClawConfig; channel: string; diff --git a/src/auto-reply/reply/agent-runner-context-recovery.test.ts b/src/auto-reply/reply/agent-runner-context-recovery.test.ts index 3e5ca490383..1cd9f0caefc 100644 --- a/src/auto-reply/reply/agent-runner-context-recovery.test.ts +++ b/src/auto-reply/reply/agent-runner-context-recovery.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; -import { - buildContextOverflowRecoveryText, - computeContextAwareReserveTokensFloor, -} from "./agent-runner-context-recovery.js"; +import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js"; function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig { return { @@ -18,45 +15,33 @@ function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig }; } -describe("computeContextAwareReserveTokensFloor", () => { - it("returns 100000 for 1M context windows", () => { - expect(computeContextAwareReserveTokensFloor(1_000_000)).toBe(100_000); - }); - - it("returns 50000 for 200k context windows", () => { - expect(computeContextAwareReserveTokensFloor(200_000)).toBe(50_000); - }); - - it("returns 35000 for 100k context windows", () => { - expect(computeContextAwareReserveTokensFloor(100_000)).toBe(35_000); - }); - - it("returns 20000 for context windows below 100k", () => { - expect(computeContextAwareReserveTokensFloor(99_999)).toBe(20_000); - expect(computeContextAwareReserveTokensFloor(32_768)).toBe(20_000); - expect(computeContextAwareReserveTokensFloor(50_000)).toBe(20_000); - }); - - it("returns 20000 for undefined context window", () => { - expect(computeContextAwareReserveTokensFloor(undefined)).toBe(20_000); - }); - - it("returns 20000 for non-positive context window", () => { - expect(computeContextAwareReserveTokensFloor(0)).toBe(20_000); - expect(computeContextAwareReserveTokensFloor(-1)).toBe(20_000); - }); - - it("returns correct tiers at exact boundaries", () => { - expect(computeContextAwareReserveTokensFloor(100_000)).toBe(35_000); - expect(computeContextAwareReserveTokensFloor(200_000)).toBe(50_000); - expect(computeContextAwareReserveTokensFloor(1_000_000)).toBe(100_000); - expect(computeContextAwareReserveTokensFloor(99_999)).toBe(20_000); - expect(computeContextAwareReserveTokensFloor(199_999)).toBe(35_000); - expect(computeContextAwareReserveTokensFloor(999_999)).toBe(50_000); - }); -}); - describe("buildContextOverflowRecoveryText", () => { + it.each([ + [99_999, "20000"], + [100_000, "35000"], + [199_999, "35000"], + [200_000, "50000"], + [999_999, "50000"], + [1_000_000, "100000"], + ])("selects the reserve floor for a %i-token model", (contextTokens, expectedFloor) => { + const text = buildContextOverflowRecoveryText({ + cfg: { + models: { + providers: { + test: { + baseUrl: "https://provider.test", + models: [makeTestModel("model", contextTokens)], + }, + }, + }, + }, + primaryProvider: "test", + primaryModel: "model", + }); + + expect(text).toContain(`reserveTokensFloor\` to ${expectedFloor}`); + }); + it("keeps the generic compaction-buffer hint without heartbeat model evidence", () => { const text = buildContextOverflowRecoveryText({ cfg: {}, diff --git a/src/auto-reply/reply/agent-runner-context-recovery.ts b/src/auto-reply/reply/agent-runner-context-recovery.ts index 937be37ca47..c77832cae89 100644 --- a/src/auto-reply/reply/agent-runner-context-recovery.ts +++ b/src/auto-reply/reply/agent-runner-context-recovery.ts @@ -10,7 +10,7 @@ import type { FollowupRun } from "./queue.js"; const DEFAULT_RESERVE_TOKENS_FLOOR = 20_000; /** Computes a reserve-token floor scaled to the selected context window. */ -export function computeContextAwareReserveTokensFloor(contextWindow: number | undefined): number { +function computeContextAwareReserveTokensFloor(contextWindow: number | undefined): number { if (typeof contextWindow !== "number" || contextWindow <= 0) { return DEFAULT_RESERVE_TOKENS_FLOOR; } diff --git a/src/auto-reply/reply/agent-runner-memory.preflight-stale-tokens.test.ts b/src/auto-reply/reply/agent-runner-memory.preflight-stale-tokens.test.ts index 88c27bbb9f3..3eb61d3b2a5 100644 --- a/src/auto-reply/reply/agent-runner-memory.preflight-stale-tokens.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.preflight-stale-tokens.test.ts @@ -9,10 +9,8 @@ import { registerMemoryCapability, type MemoryFlushPlanResolver, } from "../../plugins/memory-state.test-fixtures.js"; -import { - runPreflightCompactionIfNeeded, - setAgentRunnerMemoryTestDeps, -} from "./agent-runner-memory.js"; +import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; +import { setAgentRunnerMemoryTestDeps } from "./agent-runner-memory.test-support.js"; import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js"; import type { ReplyOperation } from "./reply-run-registry.js"; diff --git a/src/auto-reply/reply/agent-runner-memory.test-support.ts b/src/auto-reply/reply/agent-runner-memory.test-support.ts new file mode 100644 index 00000000000..6f562ec8dde --- /dev/null +++ b/src/auto-reply/reply/agent-runner-memory.test-support.ts @@ -0,0 +1,19 @@ +import "./agent-runner-memory.js"; + +type AgentRunnerMemoryTestApi = { + setAgentRunnerMemoryTestDeps(overrides?: Record): void; +}; + +function getTestApi(): AgentRunnerMemoryTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.agentRunnerMemoryTestApi") + ]; + if (!api) { + throw new Error("agent runner memory test API is unavailable"); + } + return api as AgentRunnerMemoryTestApi; +} + +export function setAgentRunnerMemoryTestDeps(overrides?: Record): void { + getTestApi().setAgentRunnerMemoryTestDeps(overrides); +} diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index dac4b32b192..eba2165331c 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -20,11 +20,8 @@ import { } from "../../plugins/memory-state.test-fixtures.js"; import type { TemplateContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; -import { - runMemoryFlushIfNeeded, - runPreflightCompactionIfNeeded, - setAgentRunnerMemoryTestDeps, -} from "./agent-runner-memory.js"; +import { runMemoryFlushIfNeeded, runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; +import { setAgentRunnerMemoryTestDeps } from "./agent-runner-memory.test-support.js"; import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js"; import type { ReplyOperation } from "./reply-run-registry.js"; diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index f66aaaf9561..8fe40e3d062 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -172,7 +172,7 @@ const memoryDeps = { }; /** Overrides memory helper dependencies for tests. */ -export function setAgentRunnerMemoryTestDeps(overrides?: Partial): void { +function setAgentRunnerMemoryTestDeps(overrides?: Partial): void { Object.assign(memoryDeps, { runWithModelFallback, ensureSelectedAgentHarnessPlugin, @@ -190,6 +190,12 @@ export function setAgentRunnerMemoryTestDeps(overrides?: Partial)[Symbol.for("openclaw.agentRunnerMemoryTestApi")] = { + setAgentRunnerMemoryTestDeps, + }; +} + function estimatePromptTokensForMemoryFlush(prompt?: string): number | undefined { const trimmed = normalizeOptionalString(prompt); if (!trimmed) { diff --git a/src/auto-reply/reply/agent-runner-session-reset.test-support.ts b/src/auto-reply/reply/agent-runner-session-reset.test-support.ts new file mode 100644 index 00000000000..5057d4a2a8c --- /dev/null +++ b/src/auto-reply/reply/agent-runner-session-reset.test-support.ts @@ -0,0 +1,19 @@ +import "./agent-runner-session-reset.js"; + +type AgentRunnerSessionResetTestApi = { + setAgentRunnerSessionResetTestDeps(overrides?: Record): void; +}; + +function getTestApi(): AgentRunnerSessionResetTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.agentRunnerSessionResetTestApi") + ]; + if (!api) { + throw new Error("agent runner session reset test API is unavailable"); + } + return api as AgentRunnerSessionResetTestApi; +} + +export function setAgentRunnerSessionResetTestDeps(overrides?: Record): void { + getTestApi().setAgentRunnerSessionResetTestDeps(overrides); +} diff --git a/src/auto-reply/reply/agent-runner-session-reset.test.ts b/src/auto-reply/reply/agent-runner-session-reset.test.ts index 48edd2aa76f..35238ad03f2 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.test.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.test.ts @@ -10,10 +10,8 @@ import { loadTranscriptEvents, } from "../../config/sessions/session-accessor.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; -import { - resetReplyRunSession, - setAgentRunnerSessionResetTestDeps, -} from "./agent-runner-session-reset.js"; +import { resetReplyRunSession } from "./agent-runner-session-reset.js"; +import { setAgentRunnerSessionResetTestDeps } from "./agent-runner-session-reset.test-support.js"; import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js"; const refreshQueuedFollowupSessionMock = vi.fn(); diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index d25cff13ca8..c0b78473141 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -25,7 +25,7 @@ const deps = { error: (message: string) => defaultRuntime.error(message), }; -export function setAgentRunnerSessionResetTestDeps(overrides?: Partial): void { +function setAgentRunnerSessionResetTestDeps(overrides?: Partial): void { Object.assign(deps, { generateSecureUuid, persistSessionResetLifecycle, @@ -35,6 +35,12 @@ export function setAgentRunnerSessionResetTestDeps(overrides?: Partial)[ + Symbol.for("openclaw.agentRunnerSessionResetTestApi") + ] = { setAgentRunnerSessionResetTestDeps }; +} + export async function resetReplyRunSession(params: { options: ResetSessionOptions; sessionKey?: string; diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 871aca235bb..938a947b351 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -5,10 +5,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js"; import { - testing as embeddedRunTesting, abortEmbeddedAgentRun, isEmbeddedAgentRunActive, } from "../../agents/embedded-agent-runner/runs.js"; +import { testing as embeddedRunTesting } from "../../agents/embedded-agent-runner/runs.test-support.js"; import { clearRuntimeConfigSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; import * as sessionTypesModule from "../../config/sessions.js"; @@ -32,11 +32,8 @@ import type { VerboseLevel } from "../thinking.shared.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; -import { - createReplyOperation, - testing as replyRunRegistryTesting, - replyRunRegistry, -} from "./reply-run-registry.js"; +import { createReplyOperation, replyRunRegistry } from "./reply-run-registry.js"; +import { testing as replyRunRegistryTesting } from "./reply-run-registry.test-support.js"; import { createMockTypingController } from "./test-helpers.js"; function createCliBackendTestConfig() { diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 64983b40ac6..86467213034 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -29,11 +29,8 @@ import { REPLY_OPERATION_RUN_STATE, type ReplyOperationRunState, } from "./reply-operation-run-state.js"; -import { - createReplyOperation, - testing as replyRunTesting, - type ReplyOperation, -} from "./reply-run-registry.js"; +import { createReplyOperation, type ReplyOperation } from "./reply-run-registry.js"; +import { testing as replyRunTesting } from "./reply-run-registry.test-support.js"; import { consumeReplyUsageState } from "./reply-usage-state.js"; import { createMockTypingController } from "./test-helpers.js"; diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 8d8dc50eaab..7410246f2e3 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -145,8 +145,8 @@ vi.mock("../../infra/outbound/session-binding-service.js", async () => { const { handleAcpCommand } = await import("./commands-acp.js"); const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js"); const { testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); -const { testing: acpResetTargetTesting, resolveEffectiveResetTargetSessionKey } = - await import("./acp-reset-target.js"); +const { resolveEffectiveResetTargetSessionKey } = await import("./acp-reset-target.js"); +const { testing: acpResetTargetTesting } = await import("./acp-reset-target.test-support.js"); const { createTaskRecord } = await import("../../tasks/task-registry.js"); const { resetTaskRegistryForTests } = await import("../../tasks/task-runtime.test-helpers.js"); const { configureTaskRegistryRuntime } = await import("../../tasks/task-registry.store.js"); diff --git a/src/auto-reply/reply/commands-login.test-support.ts b/src/auto-reply/reply/commands-login.test-support.ts new file mode 100644 index 00000000000..fd6956c4788 --- /dev/null +++ b/src/auto-reply/reply/commands-login.test-support.ts @@ -0,0 +1,21 @@ +import "./commands-login.js"; + +type CommandsLoginTestApi = { + clearActiveFlows(): void; +}; + +function getTestApi(): CommandsLoginTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.commandsLoginTestApi") + ]; + if (!api) { + throw new Error("commands login test API is unavailable"); + } + return api as CommandsLoginTestApi; +} + +export const testing = { + clearActiveFlows(): void { + getTestApi().clearActiveFlows(); + }, +}; diff --git a/src/auto-reply/reply/commands-login.test.ts b/src/auto-reply/reply/commands-login.test.ts index 33491bd3ebd..8214171b0c7 100644 --- a/src/auto-reply/reply/commands-login.test.ts +++ b/src/auto-reply/reply/commands-login.test.ts @@ -27,7 +27,8 @@ vi.mock("../../config/sessions/session-accessor.js", async () => { }; }); -const { handleLoginCommand, testing } = await import("./commands-login.js"); +const { handleLoginCommand } = await import("./commands-login.js"); +const { testing } = await import("./commands-login.test-support.js"); const { loadCommandHandlers } = await import("./commands-handlers.runtime.js"); const { handlePluginCommand } = await import("./commands-plugin.js"); diff --git a/src/auto-reply/reply/commands-login.ts b/src/auto-reply/reply/commands-login.ts index 83e97a66350..700f0ea9d2a 100644 --- a/src/auto-reply/reply/commands-login.ts +++ b/src/auto-reply/reply/commands-login.ts @@ -343,9 +343,13 @@ export const handleLoginCommand: CommandHandler = async (params, allowTextComman return { shouldContinue: false, reply }; }; -export const testing = { +const commandsLoginTestApi = { clearActiveFlows() { activeCodexLoginFlows.clear(); }, - resolveCodexLoginProvider: codexChannelLoginRuntime.resolveProvider, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.commandsLoginTestApi")] = + commandsLoginTestApi; +} diff --git a/src/auto-reply/reply/dispatch-from-config.acp-abort.test.ts b/src/auto-reply/reply/dispatch-from-config.acp-abort.test.ts index 5a41b5e4053..8d4b3ba28f1 100644 --- a/src/auto-reply/reply/dispatch-from-config.acp-abort.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.acp-abort.test.ts @@ -32,7 +32,7 @@ let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; let replyRunRegistry: typeof import("./reply-run-registry.js").replyRunRegistry; let getActiveReplyRunCount: typeof import("./reply-run-registry.js").getActiveReplyRunCount; let createReplyOperation: typeof import("./reply-run-registry.js").createReplyOperation; -let replyRunTesting: typeof import("./reply-run-registry.js").testing; +let replyRunTesting: typeof import("./reply-run-registry.test-support.js").testing; function shouldUseAcpReplyDispatchHook(eventUnknown: unknown): boolean { const event = eventUnknown as { @@ -156,12 +156,9 @@ describe("dispatchReplyFromConfig ACP abort", () => { ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); ({ tryDispatchAcpReplyHook } = await import("../../plugin-sdk/acp-runtime.js")); ({ resetInboundDedupe } = await import("./inbound-dedupe.js")); - ({ - replyRunRegistry, - getActiveReplyRunCount, - createReplyOperation, - testing: replyRunTesting, - } = await import("./reply-run-registry.js")); + ({ replyRunRegistry, getActiveReplyRunCount, createReplyOperation } = + await import("./reply-run-registry.js")); + ({ testing: replyRunTesting } = await import("./reply-run-registry.test-support.js")); }); beforeEach(() => { diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 93cd2b1b23a..88cc097f5cc 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -31,7 +31,7 @@ let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; let createReplyOperation: typeof import("./reply-run-registry.js").createReplyOperation; let replyRunRegistry: typeof import("./reply-run-registry.js").replyRunRegistry; let runAfterReplyOperationClear: typeof import("./reply-run-registry.js").runAfterReplyOperationClear; -let resetReplyRunRegistry: typeof import("./reply-run-registry.js").testing.resetReplyRunRegistry; +let resetReplyRunRegistry: typeof import("./reply-run-registry.test-support.js").testing.resetReplyRunRegistry; const REPLY_RUN_FINALIZATION_SETTLE_TIMEOUT_MS = 60_000; @@ -73,7 +73,8 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { createReplyOperation = replyRunRegistryModule.createReplyOperation; replyRunRegistry = replyRunRegistryModule.replyRunRegistry; runAfterReplyOperationClear = replyRunRegistryModule.runAfterReplyOperationClear; - resetReplyRunRegistry = () => replyRunRegistryModule.testing.resetReplyRunRegistry(); + const { testing } = await import("./reply-run-registry.test-support.js"); + resetReplyRunRegistry = () => testing.resetReplyRunRegistry(); }); beforeEach(() => { diff --git a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts index 6dc16240b8f..f34aa98c8a7 100644 --- a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts @@ -14,7 +14,7 @@ import { buildTestCtx } from "./test-ctx.js"; let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig; let createReplyOperation: typeof import("./reply-run-registry.js").createReplyOperation; -let replyRunTesting: typeof import("./reply-run-registry.js").testing; +let replyRunTesting: typeof import("./reply-run-registry.test-support.js").testing; let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; const sessionKey = "agent:main:telegram:direct:1"; @@ -49,7 +49,8 @@ function createVisibleDispatchParams(replyResolver: () => Promise) describe("dispatchReplyFromConfig stale visible admission recovery", () => { beforeAll(async () => { ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); - ({ createReplyOperation, testing: replyRunTesting } = await import("./reply-run-registry.js")); + ({ createReplyOperation } = await import("./reply-run-registry.js")); + ({ testing: replyRunTesting } = await import("./reply-run-registry.test-support.js")); ({ resetInboundDedupe } = await import("./inbound-dedupe.js")); }); diff --git a/src/auto-reply/reply/dispatch-from-config.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.test-harness.ts index 26e44d63925..04ee065fcbc 100644 --- a/src/auto-reply/reply/dispatch-from-config.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.test-harness.ts @@ -71,7 +71,7 @@ export const automaticDirectReplyConfig = { export let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig; -export let dispatchFromConfigTesting: typeof import("./dispatch-from-config.js").testing; +export let dispatchFromConfigTesting: typeof import("./dispatch-from-config.test-support.js").testing; export let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; @@ -81,7 +81,7 @@ export let createReplyOperation: typeof import("./reply-run-registry.js").create export let replyRunRegistry: typeof import("./reply-run-registry.js").replyRunRegistry; -export let replyRunTesting: typeof import("./reply-run-registry.js").testing; +export let replyRunTesting: typeof import("./reply-run-registry.test-support.js").testing; export let admitReplyTurn: typeof import("./reply-turn-admission.js").admitReplyTurn; @@ -349,19 +349,16 @@ export function messageAuditEvents(): Array> { } export const globalBeforeAll0 = async () => { - ({ dispatchReplyFromConfig, testing: dispatchFromConfigTesting } = - await import("./dispatch-from-config.js")); + ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); + ({ testing: dispatchFromConfigTesting } = await import("./dispatch-from-config.test-support.js")); await import("./dispatch-acp.js"); await import("./dispatch-acp-command-bypass.js"); await import("./dispatch-acp-tts.runtime.js"); await import("./dispatch-acp-session.runtime.js"); ({ resetInboundDedupe } = await import("./inbound-dedupe.js")); ({ tryDispatchAcpReplyHook } = await import("../../plugin-sdk/acp-runtime.js")); - ({ - createReplyOperation, - replyRunRegistry, - testing: replyRunTesting, - } = await import("./reply-run-registry.js")); + ({ createReplyOperation, replyRunRegistry } = await import("./reply-run-registry.js")); + ({ testing: replyRunTesting } = await import("./reply-run-registry.test-support.js")); ({ admitReplyTurn, runWithReplyOperationLifecycleAdmission } = await import("./reply-turn-admission.js")); }; diff --git a/src/auto-reply/reply/dispatch-from-config.test-support.ts b/src/auto-reply/reply/dispatch-from-config.test-support.ts new file mode 100644 index 00000000000..ebba7c898cf --- /dev/null +++ b/src/auto-reply/reply/dispatch-from-config.test-support.ts @@ -0,0 +1,21 @@ +import "./dispatch-from-config.js"; + +type DispatchFromConfigTestApi = { + createReplyDispatchEvent(params: unknown): unknown; +}; + +function getTestApi(): DispatchFromConfigTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.dispatchFromConfigTestApi") + ]; + if (!api) { + throw new Error("dispatch-from-config test API is unavailable"); + } + return api as DispatchFromConfigTestApi; +} + +export const testing = { + createReplyDispatchEvent(params: unknown): unknown { + return getTestApi().createReplyDispatchEvent(params); + }, +}; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 3c85eaa28fa..b63fe754894 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -194,10 +194,11 @@ function createReplyDispatchEvent( }) as PluginHookReplyDispatchEvent; } -/** Test-only hooks for overriding selected dispatch dependencies. */ -export const testing = { - createReplyDispatchEvent, -}; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.dispatchFromConfigTestApi")] = { + createReplyDispatchEvent, + }; +} export type { DispatchFromConfigResult } from "./dispatch-from-config.types.js"; diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 1411cbaede3..7dcded2f921 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -49,7 +49,7 @@ let createMockTypingController: typeof import("./test-helpers.js").createMockTyp let createReplyOperationForTest: typeof import("./reply-run-registry.js").createReplyOperation; let abortActiveReplyRunsForTest: typeof import("./reply-run-registry.js").abortActiveReplyRuns; let replyRunRegistryForTest: typeof import("./reply-run-registry.js").replyRunRegistry; -let replyRunTestingForTest: typeof import("./reply-run-registry.js").testing; +let replyRunTestingForTest: typeof import("./reply-run-registry.test-support.js").testing; let cliBackendsTestingForTest: typeof import("../../agents/cli-backends.test-support.js").testing; let setReplyPayloadMetadataForTest: typeof import("../reply-payload.js").setReplyPayloadMetadata; let getReplyPayloadMetadataForTest: typeof import("../reply-payload.js").getReplyPayloadMetadata; @@ -531,8 +531,8 @@ async function loadFreshFollowupRunnerModuleForTest() { abortActiveReplyRuns: abortActiveReplyRunsForTest, createReplyOperation: createReplyOperationForTest, replyRunRegistry: replyRunRegistryForTest, - testing: replyRunTestingForTest, } = await import("./reply-run-registry.js")); + ({ testing: replyRunTestingForTest } = await import("./reply-run-registry.test-support.js")); ({ getReplyPayloadMetadata: getReplyPayloadMetadataForTest, setReplyPayloadMetadata: setReplyPayloadMetadataForTest, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 6644c5f0934..f48d8dd4248 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -166,7 +166,7 @@ let buildGroupChatContext: typeof import("./groups.js").buildGroupChatContext; let buildInboundUserContextPrefix: typeof import("./inbound-meta.js").buildInboundUserContextPrefix; let resolveInboundUserContextPromptJoiner: typeof import("./inbound-meta.js").resolveInboundUserContextPromptJoiner; let getActiveReplyRunCount: typeof import("./reply-run-registry.js").getActiveReplyRunCount; -let replyRunTesting: typeof import("./reply-run-registry.js").testing; +let replyRunTesting: typeof import("./reply-run-registry.test-support.js").testing; function createGatewayDrainingError(): Error { const error = new Error("Gateway is draining for restart; new tasks are not accepted"); @@ -306,8 +306,8 @@ describe("runPreparedReply media-only handling", () => { await import("./groups.js")); ({ buildInboundUserContextPrefix, resolveInboundUserContextPromptJoiner } = await import("./inbound-meta.js")); - ({ testing: replyRunTesting, getActiveReplyRunCount } = - await import("./reply-run-registry.js")); + ({ getActiveReplyRunCount } = await import("./reply-run-registry.js")); + ({ testing: replyRunTesting } = await import("./reply-run-registry.test-support.js")); // Load deferred reply dependencies before per-case timing starts. await runPreparedReply(baseParams()); diff --git a/src/auto-reply/reply/queue.dedupe.test.ts b/src/auto-reply/reply/queue.dedupe.test.ts index f8c339e5bb9..38b58af3b5f 100644 --- a/src/auto-reply/reply/queue.dedupe.test.ts +++ b/src/auto-reply/reply/queue.dedupe.test.ts @@ -8,7 +8,7 @@ import { createQueueTestRun as createRun, installQueueRuntimeErrorSilencer, } from "./queue.test-helpers.js"; -import { resetRecentQueuedMessageIdDedupe } from "./queue/enqueue.js"; +import { resetRecentQueuedMessageIdDedupe } from "./queue/enqueue.test-support.js"; installQueueRuntimeErrorSilencer(); @@ -206,8 +206,7 @@ describe("followup queue deduplication", () => { const key = `test-dedup-cross-module-${Date.now()}`; const { calls, done, runFollowup } = createFollowupCollector(); - enqueueA.resetRecentQueuedMessageIdDedupe(); - enqueueB.resetRecentQueuedMessageIdDedupe(); + resetRecentQueuedMessageIdDedupe(); try { expect( @@ -244,8 +243,7 @@ describe("followup queue deduplication", () => { expect(calls).toHaveLength(1); } finally { clearSessionQueues([key]); - enqueueA.resetRecentQueuedMessageIdDedupe(); - enqueueB.resetRecentQueuedMessageIdDedupe(); + resetRecentQueuedMessageIdDedupe(); } }); diff --git a/src/auto-reply/reply/queue.drain-restart.test.ts b/src/auto-reply/reply/queue.drain-restart.test.ts index 44d701eaa47..394ff0a4fde 100644 --- a/src/auto-reply/reply/queue.drain-restart.test.ts +++ b/src/auto-reply/reply/queue.drain-restart.test.ts @@ -21,6 +21,7 @@ import { createQueueTestRun as createRun, installQueueRuntimeErrorSilencer, } from "./queue.test-helpers.js"; +import { resetRecentQueuedMessageIdDedupe } from "./queue/enqueue.test-support.js"; import { clearFollowupQueue, getExistingFollowupQueue } from "./queue/state.js"; installQueueRuntimeErrorSilencer(); @@ -261,7 +262,7 @@ describe("followup queue drain restart after idle window", () => { const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 }; const firstProcessed = createDeferred(); - enqueueB.resetRecentQueuedMessageIdDedupe(); + resetRecentQueuedMessageIdDedupe(); try { const runFollowup = async (run: FollowupRun) => { @@ -299,7 +300,7 @@ describe("followup queue drain restart after idle window", () => { } finally { clearSessionQueues([key]); drainA.clearFollowupDrainCallback(key); - enqueueB.resetRecentQueuedMessageIdDedupe(); + resetRecentQueuedMessageIdDedupe(); } }); diff --git a/src/auto-reply/reply/queue/cleanup.test-support.ts b/src/auto-reply/reply/queue/cleanup.test-support.ts new file mode 100644 index 00000000000..0d5db189ddd --- /dev/null +++ b/src/auto-reply/reply/queue/cleanup.test-support.ts @@ -0,0 +1,32 @@ +import type { resolveEmbeddedSessionLane } from "../../../agents/embedded-agent-runner/lanes.js"; +import type { clearCommandLane } from "../../../process/command-queue.js"; +import "./cleanup.js"; + +type QueueCleanupTestDeps = { + resolveEmbeddedSessionLane: typeof resolveEmbeddedSessionLane; + clearCommandLane: typeof clearCommandLane; +}; + +type QueueCleanupTestApi = { + setDepsForTests(deps: Partial | undefined): void; + resetDepsForTests(): void; +}; + +function getTestApi(): QueueCleanupTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.queueCleanupTestApi") + ]; + if (!api) { + throw new Error("queue cleanup test API is unavailable"); + } + return api as QueueCleanupTestApi; +} + +export const testing = { + setDepsForTests(deps: Partial | undefined): void { + getTestApi().setDepsForTests(deps); + }, + resetDepsForTests(): void { + getTestApi().resetDepsForTests(); + }, +}; diff --git a/src/auto-reply/reply/queue/cleanup.test.ts b/src/auto-reply/reply/queue/cleanup.test.ts index 6ba0ebb5577..03d17157257 100644 --- a/src/auto-reply/reply/queue/cleanup.test.ts +++ b/src/auto-reply/reply/queue/cleanup.test.ts @@ -1,6 +1,7 @@ // Tests queue cleanup behavior for expired state and dedupe records. import { afterEach, describe, expect, it, vi } from "vitest"; -import { testing, clearSessionQueues } from "./cleanup.js"; +import { clearSessionQueues } from "./cleanup.js"; +import { testing } from "./cleanup.test-support.js"; const followupQueueMocks = vi.hoisted(() => ({ clearFollowupDrainCallback: vi.fn(), diff --git a/src/auto-reply/reply/queue/cleanup.ts b/src/auto-reply/reply/queue/cleanup.ts index 9ab64cf7ce9..c3ebba90a9d 100644 --- a/src/auto-reply/reply/queue/cleanup.ts +++ b/src/auto-reply/reply/queue/cleanup.ts @@ -32,7 +32,7 @@ function resolveQueueCleanupLaneClearer() { : defaultQueueCleanupDeps.clearCommandLane; } -export const testing = { +const queueCleanupTestApi = { setDepsForTests(deps: Partial | undefined): void { queueCleanupDeps.resolveEmbeddedSessionLane = typeof deps?.resolveEmbeddedSessionLane === "function" @@ -50,6 +50,11 @@ export const testing = { }, }; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.queueCleanupTestApi")] = + queueCleanupTestApi; +} + export function clearSessionQueues(keys: Array): ClearSessionQueueResult { const seen = new Set(); let followupCleared = 0; diff --git a/src/auto-reply/reply/queue/enqueue.test-support.ts b/src/auto-reply/reply/queue/enqueue.test-support.ts new file mode 100644 index 00000000000..30dd02ea133 --- /dev/null +++ b/src/auto-reply/reply/queue/enqueue.test-support.ts @@ -0,0 +1,19 @@ +import "./enqueue.js"; + +type QueueEnqueueTestApi = { + resetRecentQueuedMessageIdDedupe(): void; +}; + +function getTestApi(): QueueEnqueueTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.queueEnqueueTestApi") + ]; + if (!api) { + throw new Error("queue enqueue test API is unavailable"); + } + return api as QueueEnqueueTestApi; +} + +export function resetRecentQueuedMessageIdDedupe(): void { + getTestApi().resetRecentQueuedMessageIdDedupe(); +} diff --git a/src/auto-reply/reply/queue/enqueue.ts b/src/auto-reply/reply/queue/enqueue.ts index f031dac8b5c..94ec2e05529 100644 --- a/src/auto-reply/reply/queue/enqueue.ts +++ b/src/auto-reply/reply/queue/enqueue.ts @@ -222,6 +222,12 @@ export function getFollowupQueueDepth(key: string): number { return countPendingQueueItems(queue.items, queue.inFlight); } -export function resetRecentQueuedMessageIdDedupe(): void { +function resetRecentQueuedMessageIdDedupe(): void { RECENT_QUEUE_MESSAGE_IDS.clear(); } + +if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.queueEnqueueTestApi")] = { + resetRecentQueuedMessageIdDedupe, + }; +} diff --git a/src/auto-reply/reply/reply-run-registry.test-support.ts b/src/auto-reply/reply/reply-run-registry.test-support.ts new file mode 100644 index 00000000000..9c07aafd71d --- /dev/null +++ b/src/auto-reply/reply/reply-run-registry.test-support.ts @@ -0,0 +1,21 @@ +import "./reply-run-registry.js"; + +type ReplyRunRegistryTestApi = { + resetReplyRunRegistry(): void; +}; + +function getTestApi(): ReplyRunRegistryTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.replyRunRegistryTestApi") + ]; + if (!api) { + throw new Error("reply run registry test API is unavailable"); + } + return api as ReplyRunRegistryTestApi; +} + +export const testing = { + resetReplyRunRegistry(): void { + getTestApi().resetReplyRunRegistry(); + }, +}; diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index 70dff21d8ee..44b49c69994 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -10,7 +10,6 @@ import { diagnosticLogger } from "../../logging/diagnostic-runtime.js"; import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js"; import { beginReplyOperationFinalizationWork } from "./reply-run-finalization-lease.js"; import { - testing, abortActiveReplyRuns, createReplyOperation, expireStaleReplyOperation, @@ -29,6 +28,7 @@ import { resolveReplyRunPhaseForSessionId, waitForReplyRunEndBySessionId, } from "./reply-run-registry.js"; +import { testing } from "./reply-run-registry.test-support.js"; import { admitReplyTurn } from "./reply-turn-admission.js"; const REPLY_RUN_FINALIZATION_SETTLE_TIMEOUT_MS = 60_000; diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index ac5fb118c1b..81c05271096 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -1281,7 +1281,7 @@ export function listActiveReplyRunSessionKeys(): string[] { return [...replyRunState.activeSessionIdsByKey.keys()]; } -export const testing = { +const replyRunRegistryTestApi = { resetReplyRunRegistry(): void { for (const [sessionKey, sessionId] of replyRunState.activeSessionIdsByKey) { markReplyRunDiagnosticProgress({ @@ -1304,4 +1304,9 @@ export const testing = { replyRunState.followupAdmissionBarriersByKey.clear(); }, }; + +if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.replyRunRegistryTestApi")] = + replyRunRegistryTestApi; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/auto-reply/reply/reply-turn-admission.test.ts b/src/auto-reply/reply/reply-turn-admission.test.ts index 9b8d739336e..9ae432704ce 100644 --- a/src/auto-reply/reply/reply-turn-admission.test.ts +++ b/src/auto-reply/reply/reply-turn-admission.test.ts @@ -23,9 +23,9 @@ import { REPLY_RUN_TERMINAL_SETTLE_TIMEOUT_MS, replyRunRegistry, runAfterReplyOperationClear, - testing, type ReplyOperation, } from "./reply-run-registry.js"; +import { testing } from "./reply-run-registry.test-support.js"; import { admitReplyTurn, runWithReplyOperationLifecycleAdmission } from "./reply-turn-admission.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); diff --git a/src/auto-reply/reply/session-reset-cleanup.test.ts b/src/auto-reply/reply/session-reset-cleanup.test.ts index 496534012d0..494186f4c7e 100644 --- a/src/auto-reply/reply/session-reset-cleanup.test.ts +++ b/src/auto-reply/reply/session-reset-cleanup.test.ts @@ -10,11 +10,8 @@ import { resetSystemEventsForTest, } from "../../infra/system-events.js"; import { resetDiagnosticRunActivityForTest } from "../../logging/diagnostic-run-activity.js"; -import { - createReplyOperation, - replyRunRegistry, - testing as replyRunTesting, -} from "./reply-run-registry.js"; +import { createReplyOperation, replyRunRegistry } from "./reply-run-registry.js"; +import { testing as replyRunTesting } from "./reply-run-registry.test-support.js"; import { clearSessionResetRuntimeState } from "./session-reset-cleanup.js"; afterEach(() => { diff --git a/src/auto-reply/reply/stage-sandbox-media.scp.test.ts b/src/auto-reply/reply/stage-sandbox-media.scp.test.ts index 1e8c121b884..e1c84ae42d3 100644 --- a/src/auto-reply/reply/stage-sandbox-media.scp.test.ts +++ b/src/auto-reply/reply/stage-sandbox-media.scp.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { testing } from "./stage-sandbox-media.js"; +import { testing } from "./stage-sandbox-media.test-support.js"; const SCP_STDERR_TAIL_CHARS = 16_384; diff --git a/src/auto-reply/reply/stage-sandbox-media.test-support.ts b/src/auto-reply/reply/stage-sandbox-media.test-support.ts new file mode 100644 index 00000000000..eb1fe2c01c9 --- /dev/null +++ b/src/auto-reply/reply/stage-sandbox-media.test-support.ts @@ -0,0 +1,21 @@ +import "./stage-sandbox-media.js"; + +type StageSandboxMediaTestApi = { + scpFile(remoteHost: string, remotePath: string, localPath: string): Promise; +}; + +function getTestApi(): StageSandboxMediaTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.stageSandboxMediaTestApi") + ]; + if (!api) { + throw new Error("sandbox media test API is unavailable"); + } + return api as StageSandboxMediaTestApi; +} + +export const testing = { + async scpFile(remoteHost: string, remotePath: string, localPath: string): Promise { + await getTestApi().scpFile(remoteHost, remotePath, localPath); + }, +}; diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 831b967cc4d..6676d02e63c 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -403,4 +403,8 @@ function appendScpStderrTail( return sliceUtf16Safe(combined, Math.max(0, combined.length - maxChars)); } -export const testing = { scpFile } as const; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.stageSandboxMediaTestApi")] = { + scpFile, + }; +} diff --git a/src/auto-reply/usage-bar/template.test-support.ts b/src/auto-reply/usage-bar/template.test-support.ts new file mode 100644 index 00000000000..951cd8de5a6 --- /dev/null +++ b/src/auto-reply/usage-bar/template.test-support.ts @@ -0,0 +1,19 @@ +import "./template.js"; + +type UsageBarTemplateTestApi = { + clearUsageBarTemplateCacheForTest(): void; +}; + +function getTestApi(): UsageBarTemplateTestApi { + const api = (globalThis as Record)[ + Symbol.for("openclaw.usageBarTemplateTestApi") + ]; + if (!api) { + throw new Error("usage bar template test API is unavailable"); + } + return api as UsageBarTemplateTestApi; +} + +export function clearUsageBarTemplateCacheForTest(): void { + getTestApi().clearUsageBarTemplateCacheForTest(); +} diff --git a/src/auto-reply/usage-bar/template.test.ts b/src/auto-reply/usage-bar/template.test.ts index 68b72c16fcd..546970e850f 100644 --- a/src/auto-reply/usage-bar/template.test.ts +++ b/src/auto-reply/usage-bar/template.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js"; -import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js"; +import { loadUsageBarTemplate } from "./template.js"; +import { clearUsageBarTemplateCacheForTest } from "./template.test-support.js"; const warnSpy = vi.hoisted(() => vi.fn()); diff --git a/src/auto-reply/usage-bar/template.ts b/src/auto-reply/usage-bar/template.ts index 1e816a28343..8ccc53b0d70 100644 --- a/src/auto-reply/usage-bar/template.ts +++ b/src/auto-reply/usage-bar/template.ts @@ -186,10 +186,16 @@ export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarT ); } -export function clearUsageBarTemplateCacheForTest(): void { +function clearUsageBarTemplateCacheForTest(): void { for (const entry of fileCache.values()) { entry.watcher?.close(); } fileCache.clear(); warnedTemplateOverrides.clear(); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.usageBarTemplateTestApi")] = { + clearUsageBarTemplateCacheForTest, + }; +} diff --git a/src/auto-reply/usage-bar/template.watcher-proof.test.ts b/src/auto-reply/usage-bar/template.watcher-proof.test.ts index 04dfca4758e..032c295eaea 100644 --- a/src/auto-reply/usage-bar/template.watcher-proof.test.ts +++ b/src/auto-reply/usage-bar/template.watcher-proof.test.ts @@ -10,7 +10,8 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js"; +import { loadUsageBarTemplate } from "./template.js"; +import { clearUsageBarTemplateCacheForTest } from "./template.test-support.js"; const state = vi.hoisted(() => ({ created: 0, diff --git a/src/cli/channel-options.test-support.ts b/src/cli/channel-options.test-support.ts new file mode 100644 index 00000000000..880d55b9428 --- /dev/null +++ b/src/cli/channel-options.test-support.ts @@ -0,0 +1,17 @@ +import "./channel-options.js"; + +type CliChannelOptionsTestApi = { + resetPrecomputedChannelOptionsForTests(): void; +}; + +function getTestApi(): CliChannelOptionsTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.cliChannelOptionsTestApi") + ] as CliChannelOptionsTestApi; +} + +export const testing = { + resetPrecomputedChannelOptionsForTests(): void { + getTestApi().resetPrecomputedChannelOptionsForTests(); + }, +}; diff --git a/src/cli/channel-options.test.ts b/src/cli/channel-options.test.ts index 003b770d667..d39bcc4b25c 100644 --- a/src/cli/channel-options.test.ts +++ b/src/cli/channel-options.test.ts @@ -1,7 +1,8 @@ // Channel option tests cover channel command option parsing and config resolution. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { testing, formatCliChannelOptions, resolveCliChannelOptions } from "./channel-options.js"; -import { testing as startupMetadataTesting } from "./startup-metadata.js"; +import { formatCliChannelOptions, resolveCliChannelOptions } from "./channel-options.js"; +import { testing } from "./channel-options.test-support.js"; +import { testing as startupMetadataTesting } from "./startup-metadata.test-support.js"; const readFileSyncMock = vi.hoisted(() => vi.fn()); diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index d1adf57c3c2..b4a67e71864 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -35,8 +35,13 @@ export function formatCliChannelOptions(extra: string[] = []): string { return options.length > 0 ? options.join("|") : "channel"; } -export const testing = { +const testing = { resetPrecomputedChannelOptionsForTests(): void { precomputedChannelOptions = undefined; }, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.cliChannelOptionsTestApi")] = + testing; +} diff --git a/src/cli/command-secret-gateway.test-support.ts b/src/cli/command-secret-gateway.test-support.ts new file mode 100644 index 00000000000..e9e4da5bef1 --- /dev/null +++ b/src/cli/command-secret-gateway.test-support.ts @@ -0,0 +1,34 @@ +import type { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry.js"; +import type { analyzeCommandSecretAssignmentsFromSnapshot } from "../secrets/command-config.js"; +import type { collectConfigAssignments } from "../secrets/runtime-config-collectors.js"; +import type { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js"; +import type { discoverConfigSecretTargetsByIds } from "../secrets/target-registry.js"; +import "./command-secret-gateway.js"; + +type CommandSecretGatewayTestDeps = { + analyzeCommandSecretAssignmentsFromSnapshot: typeof analyzeCommandSecretAssignmentsFromSnapshot; + collectConfigAssignments: typeof collectConfigAssignments; + discoverConfigSecretTargetsByIds: typeof discoverConfigSecretTargetsByIds; + resolveManifestContractOwnerPluginId: typeof resolveManifestContractOwnerPluginId; + resolveRuntimeWebTools: typeof resolveRuntimeWebTools; +}; + +type CommandSecretGatewayTestApi = { + setDepsForTest(overrides: Partial): () => void; + resetDepsForTest(): void; +}; + +function getTestApi(): CommandSecretGatewayTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.commandSecretGatewayTestApi") + ] as CommandSecretGatewayTestApi; +} + +export const testing = { + setDepsForTest(overrides: Partial): () => void { + return getTestApi().setDepsForTest(overrides); + }, + resetDepsForTest(): void { + getTestApi().resetDepsForTest(); + }, +}; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 10cf15e3cee..b878182dfd8 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -11,10 +11,8 @@ import { TALK_TEST_PROVIDER_API_KEY_PATH, TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS, } from "../test-utils/talk-test-provider.js"; -import { - testing as commandSecretGatewayTesting, - resolveCommandSecretRefsViaGateway, -} from "./command-secret-gateway.js"; +import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; +import { testing as commandSecretGatewayTesting } from "./command-secret-gateway.test-support.js"; const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 58abf42ca37..d88140ba1dc 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -98,7 +98,7 @@ const commandSecretGatewayDeps: CommandSecretGatewayDeps = { resolveRuntimeWebTools, }; -export const testing = { +const testing = { setDepsForTest(overrides: Partial): () => void { const previous = { ...commandSecretGatewayDeps }; Object.assign(commandSecretGatewayDeps, overrides); @@ -117,6 +117,11 @@ export const testing = { }, }; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.commandSecretGatewayTestApi")] = + testing; +} + function pluginIdFromRuntimeWebPath(path: string): string | undefined { const match = /^plugins\.entries\.([^.]+)\.config\.(webSearch|webFetch)\.apiKey$/.exec(path); return match?.[1]; diff --git a/src/cli/gateway-cli/run.supervised-lock.test.ts b/src/cli/gateway-cli/run.supervised-lock.test.ts index 3a93f365e7f..7bdd4172452 100644 --- a/src/cli/gateway-cli/run.supervised-lock.test.ts +++ b/src/cli/gateway-cli/run.supervised-lock.test.ts @@ -1,7 +1,7 @@ // Gateway supervised lock tests cover single-runner locking for supervised gateway starts. import { describe, expect, it, vi } from "vitest"; import { GatewayLockError } from "../../infra/gateway-lock.js"; -import { testing } from "./run.js"; +import { testing } from "./run.test-support.js"; function createLogger() { return { diff --git a/src/cli/gateway-cli/run.test-support.ts b/src/cli/gateway-cli/run.test-support.ts new file mode 100644 index 00000000000..5bb7137a055 --- /dev/null +++ b/src/cli/gateway-cli/run.test-support.ts @@ -0,0 +1,46 @@ +import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; +import "./run.js"; + +type GatewayRunTestLogger = { + info(message: string): void; + warn(message: string): void; +}; + +type GatewayRunTestApi = { + normalizeGatewayHealthProbeHost(host: string): string; + resolveGatewayLockErrorExitCode(err: unknown, supervisor: RespawnSupervisor | null): number; + resolveGatewayStartupFailureExitCode(err: unknown): number; + runGatewayLoopWithSupervisedLockRecovery(params: { + startLoop: () => Promise; + supervisor: RespawnSupervisor | null; + port: number; + healthHost: string; + log: GatewayRunTestLogger; + now?: () => number; + sleep?: (ms: number) => Promise; + probeHealth?: (params: { host: string; port: number }) => Promise; + retryMs?: number; + timeoutMs?: number; + }): Promise; +}; + +function getTestApi(): GatewayRunTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.gatewayRunTestApi") + ] as GatewayRunTestApi; +} + +export const testing: GatewayRunTestApi = { + normalizeGatewayHealthProbeHost(host) { + return getTestApi().normalizeGatewayHealthProbeHost(host); + }, + resolveGatewayLockErrorExitCode(err, supervisor) { + return getTestApi().resolveGatewayLockErrorExitCode(err, supervisor); + }, + resolveGatewayStartupFailureExitCode(err) { + return getTestApi().resolveGatewayStartupFailureExitCode(err); + }, + async runGatewayLoopWithSupervisedLockRecovery(params) { + await getTestApi().runGatewayLoopWithSupervisedLockRecovery(params); + }, +}; diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 1185f13d427..8ab6f92a048 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -1092,10 +1092,14 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR } } -export const testing = { +const testing = { normalizeGatewayHealthProbeHost, resolveGatewayLockErrorExitCode, resolveGatewayStartupFailureExitCode, runGatewayLoopWithSupervisedLockRecovery, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.gatewayRunTestApi")] = testing; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/cli/plugins-install-command.test-support.ts b/src/cli/plugins-install-command.test-support.ts new file mode 100644 index 00000000000..e352672b876 --- /dev/null +++ b/src/cli/plugins-install-command.test-support.ts @@ -0,0 +1,29 @@ +import type { + ConfigMutationPreflight, + ConfigSnapshotForInstallPersist, +} from "../plugins/install-persistence.js"; +import type { PluginInstallRequestContext } from "./plugin-install-config-policy.js"; +import "./plugins-install-command.js"; + +type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & { + hookMutation: ConfigMutationPreflight; + pluginMutation: ConfigMutationPreflight; +}; + +type PluginsInstallCommandTestApi = { + loadConfigForInstall( + request: PluginInstallRequestContext, + ): Promise; +}; + +function getTestApi(): PluginsInstallCommandTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.pluginsInstallCommandTestApi") + ] as PluginsInstallCommandTestApi; +} + +export async function loadConfigForInstall( + request: PluginInstallRequestContext, +): Promise { + return await getTestApi().loadConfigForInstall(request); +} diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 31c3027f16a..5bb1bb54ec2 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -718,7 +718,7 @@ async function loadConfigFromSnapshotForInstall( }; } -export async function loadConfigForInstall( +async function loadConfigForInstall( request: PluginInstallRequestContext, ): Promise { const prepared = await tracePluginLifecyclePhaseAsync( @@ -749,6 +749,12 @@ export async function loadConfigForInstall( return loadConfigFromSnapshotForInstall(request, prepared); } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.pluginsInstallCommandTestApi") + ] = { loadConfigForInstall }; +} + export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index b3d8d00986d..76d6ce485f4 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -12,7 +12,7 @@ import { resolvePluginInstallRequestContext, type PluginInstallRequestContext, } from "./plugin-install-config-policy.js"; -import { loadConfigForInstall } from "./plugins-install-command.js"; +import { loadConfigForInstall } from "./plugins-install-command.test-support.js"; const hoisted = vi.hoisted(() => ({ assertConfigPathForWriteMock: vi.fn(), diff --git a/src/cli/startup-metadata.test-support.ts b/src/cli/startup-metadata.test-support.ts new file mode 100644 index 00000000000..ce49fd9e2b7 --- /dev/null +++ b/src/cli/startup-metadata.test-support.ts @@ -0,0 +1,21 @@ +import "./startup-metadata.js"; + +type CliStartupMetadataTestApi = { + resolveStartupMetadataPathCandidates(moduleUrl: string): string[]; + clearStartupMetadataCache(): void; +}; + +function getTestApi(): CliStartupMetadataTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.cliStartupMetadataTestApi") + ] as CliStartupMetadataTestApi; +} + +export const testing = { + resolveStartupMetadataPathCandidates(moduleUrl: string): string[] { + return getTestApi().resolveStartupMetadataPathCandidates(moduleUrl); + }, + clearStartupMetadataCache(): void { + getTestApi().clearStartupMetadataCache(); + }, +}; diff --git a/src/cli/startup-metadata.test.ts b/src/cli/startup-metadata.test.ts index 0be7c458a39..7acaaafe8cb 100644 --- a/src/cli/startup-metadata.test.ts +++ b/src/cli/startup-metadata.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; -import { testing } from "./startup-metadata.js"; +import { testing } from "./startup-metadata.test-support.js"; describe("startup metadata path resolution", () => { it("checks metadata beside the bundled chunk before the legacy parent path", () => { diff --git a/src/cli/startup-metadata.ts b/src/cli/startup-metadata.ts index e9bb24fff31..84230e4f0ee 100644 --- a/src/cli/startup-metadata.ts +++ b/src/cli/startup-metadata.ts @@ -36,9 +36,14 @@ export function readCliStartupMetadata(moduleUrl: string): Record)[Symbol.for("openclaw.cliStartupMetadataTestApi")] = + testing; +} diff --git a/src/cli/update-cli/update-command-plugins.test-support.ts b/src/cli/update-cli/update-command-plugins.test-support.ts new file mode 100644 index 00000000000..6511f4b4cdb --- /dev/null +++ b/src/cli/update-cli/update-command-plugins.test-support.ts @@ -0,0 +1,52 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../../config/types.plugins.js"; +import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; +import "./update-command-plugins.js"; + +type MissingPluginInstallPayload = { + pluginId: string; + installPath?: string; + reason: "missing-install-path" | "missing-package-dir" | "missing-package-json"; +}; + +type UpdateCommandPluginsTestApi = { + buildInvalidConfigPostCoreUpdateResult(): { + message: string; + guidance: string[]; + result: PostCorePluginUpdateResult; + }; + collectMissingPluginInstallPayloads(params: { + records: Record; + config?: OpenClawConfig; + skipDisabledPlugins?: boolean; + syncOfficialPluginInstalls?: boolean; + env?: NodeJS.ProcessEnv; + }): Promise; + resolvePostSyncPluginUpdateSkipIds(params: { + switchedToClawHub: readonly string[]; + switchedToNpm: readonly string[]; + repairedMissingPayloadIds: ReadonlySet; + }): Set; +}; + +function getTestApi(): UpdateCommandPluginsTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.updateCommandPluginsTestApi") + ] as UpdateCommandPluginsTestApi; +} + +export function buildInvalidConfigPostCoreUpdateResult() { + return getTestApi().buildInvalidConfigPostCoreUpdateResult(); +} + +export async function collectMissingPluginInstallPayloads( + params: Parameters[0], +): Promise { + return await getTestApi().collectMissingPluginInstallPayloads(params); +} + +export function resolvePostSyncPluginUpdateSkipIds( + params: Parameters[0], +): Set { + return getTestApi().resolvePostSyncPluginUpdateSkipIds(params); +} diff --git a/src/cli/update-cli/update-command-plugins.ts b/src/cli/update-cli/update-command-plugins.ts index 56bb9506a6e..38a3a7ce0dc 100644 --- a/src/cli/update-cli/update-command-plugins.ts +++ b/src/cli/update-cli/update-command-plugins.ts @@ -61,7 +61,7 @@ type MissingPluginInstallPayload = { type PostUpdatePluginWarning = NonNullable[number]; -export function resolvePostSyncPluginUpdateSkipIds(params: { +function resolvePostSyncPluginUpdateSkipIds(params: { switchedToClawHub: readonly string[]; switchedToNpm: readonly string[]; repairedMissingPayloadIds: ReadonlySet; @@ -142,7 +142,7 @@ function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean { ); } -export async function collectMissingPluginInstallPayloads(params: { +async function collectMissingPluginInstallPayloads(params: { records: Record; config?: OpenClawConfig; skipDisabledPlugins?: boolean; @@ -302,10 +302,8 @@ function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boo * refuse to restart the gateway and surface this as a hard error so the * existing `status === "error"` ⇒ `exit 1` pre-restart gate fires. * - * Exported for unit testing without having to drive the entire - * `updatePluginsAfterCoreUpdate` orchestrator. */ -export function buildInvalidConfigPostCoreUpdateResult(): { +function buildInvalidConfigPostCoreUpdateResult(): { message: string; guidance: string[]; result: PostCorePluginUpdateResult; @@ -340,6 +338,15 @@ export function buildInvalidConfigPostCoreUpdateResult(): { }; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.updateCommandPluginsTestApi")] = + { + buildInvalidConfigPostCoreUpdateResult, + collectMissingPluginInstallPayloads, + resolvePostSyncPluginUpdateSkipIds, + }; +} + export async function updatePluginsAfterCoreUpdate(params: { root: string; channel: UpdateChannel; diff --git a/src/cli/update-cli/update-command-service.test-support.ts b/src/cli/update-cli/update-command-service.test-support.ts new file mode 100644 index 00000000000..d44e0ffb5bb --- /dev/null +++ b/src/cli/update-cli/update-command-service.test-support.ts @@ -0,0 +1,88 @@ +import type { GatewayServiceState } from "../../daemon/service-types.js"; +import type { GatewayService } from "../../daemon/service.js"; +import type { UpdateRunResult } from "../../infra/update-runner.js"; +import type { GatewayRestartSnapshot } from "../daemon-cli/restart-health.js"; +import "./update-command-service.js"; + +type PostUpdateLaunchAgentRecoveryResult = + | { attempted: false; recovered: false } + | { attempted: true; recovered: true; message: string } + | { attempted: true; recovered: false; detail: string }; + +type UpdateCommandServiceTestApi = { + formatPostUpdateGatewayRecoveryInstructions( + result: UpdateRunResult, + platform?: NodeJS.Platform, + ): string[]; + recoverInstalledLaunchAgentAfterUpdate(params: { + service?: GatewayService; + env?: NodeJS.ProcessEnv; + deps?: { + platform?: NodeJS.Platform; + readState?: ( + service: GatewayService, + args: { env?: NodeJS.ProcessEnv }, + ) => Promise; + recover?: (params: { + result: "restarted"; + env?: Record; + }) => Promise<{ result: "restarted"; loaded: true; message: string } | null>; + }; + }): Promise; + recoverLaunchAgentAndRecheckGatewayHealth(params: { + health: GatewayRestartSnapshot; + service: GatewayService; + port: number; + expectedVersion?: string; + env?: NodeJS.ProcessEnv; + deps?: { + recoverLaunchAgent?: (params: { + service?: GatewayService; + env?: NodeJS.ProcessEnv; + }) => Promise; + waitForHealthy?: (params: { + service: GatewayService; + port: number; + expectedVersion?: string; + env?: NodeJS.ProcessEnv; + }) => Promise; + }; + }): Promise<{ + health: GatewayRestartSnapshot; + launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null; + }>; + shouldUseLegacyProcessRestartAfterUpdate(params: { + updateMode: UpdateRunResult["mode"]; + }): boolean; +}; + +function getTestApi(): UpdateCommandServiceTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.updateCommandServiceTestApi") + ] as UpdateCommandServiceTestApi; +} + +export function formatPostUpdateGatewayRecoveryInstructions( + result: UpdateRunResult, + platform?: NodeJS.Platform, +): string[] { + return getTestApi().formatPostUpdateGatewayRecoveryInstructions(result, platform); +} + +export async function recoverInstalledLaunchAgentAfterUpdate( + params: Parameters[0], +): Promise { + return await getTestApi().recoverInstalledLaunchAgentAfterUpdate(params); +} + +export async function recoverLaunchAgentAndRecheckGatewayHealth( + params: Parameters[0], +) { + return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params); +} + +export function shouldUseLegacyProcessRestartAfterUpdate( + params: Parameters[0], +): boolean { + return getTestApi().shouldUseLegacyProcessRestartAfterUpdate(params); +} diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index dde19d10cae..f6646df3921 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -109,7 +109,7 @@ export function shouldPrepareUpdatedInstallRestart(params: { return params.serviceLoaded; } -export function shouldUseLegacyProcessRestartAfterUpdate(params: { +function shouldUseLegacyProcessRestartAfterUpdate(params: { updateMode: UpdateRunResult["mode"]; }): boolean { return !isPackageManagerUpdateMode(params.updateMode); @@ -126,7 +126,7 @@ type PostUpdateLaunchAgentRecoveryDeps = { recover?: typeof recoverInstalledLaunchAgent; }; -export async function recoverInstalledLaunchAgentAfterUpdate(params: { +async function recoverInstalledLaunchAgentAfterUpdate(params: { service?: GatewayService; env?: NodeJS.ProcessEnv; deps?: PostUpdateLaunchAgentRecoveryDeps; @@ -171,7 +171,7 @@ type PostUpdateGatewayHealthRecoveryDeps = { waitForHealthy?: typeof waitForGatewayHealthyRestart; }; -export async function recoverLaunchAgentAndRecheckGatewayHealth(params: { +async function recoverLaunchAgentAndRecheckGatewayHealth(params: { health: GatewayRestartSnapshot; service: GatewayService; port: number; @@ -228,7 +228,7 @@ function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`; } -export function formatPostUpdateGatewayRecoveryInstructions( +function formatPostUpdateGatewayRecoveryInstructions( result: UpdateRunResult, platform: NodeJS.Platform = process.platform, ): string[] { @@ -242,6 +242,16 @@ export function formatPostUpdateGatewayRecoveryInstructions( return lines; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.updateCommandServiceTestApi")] = + { + formatPostUpdateGatewayRecoveryInstructions, + recoverInstalledLaunchAgentAfterUpdate, + recoverLaunchAgentAndRecheckGatewayHealth, + shouldUseLegacyProcessRestartAfterUpdate, + }; +} + export type PreManagedServiceStop = { stopped: boolean; inspected: boolean; diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 4bfa6d83a4f..b745c2dfbdd 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -6,23 +6,25 @@ import { describe, expect, it, vi } from "vitest"; import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; +import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js"; import { buildInvalidConfigPostCoreUpdateResult, collectMissingPluginInstallPayloads, resolvePostSyncPluginUpdateSkipIds, - updatePluginsAfterCoreUpdate, -} from "./update-command-plugins.js"; +} from "./update-command-plugins.test-support.js"; import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js"; import { - formatPostUpdateGatewayRecoveryInstructions, - recoverInstalledLaunchAgentAfterUpdate, - recoverLaunchAgentAndRecheckGatewayHealth, resolvePostInstallDoctorEnv, resolvePostUpdateServiceStateReadEnv, resolveUpdatedGatewayRestartPort, shouldPrepareUpdatedInstallRestart, - shouldUseLegacyProcessRestartAfterUpdate, } from "./update-command-service.js"; +import { + formatPostUpdateGatewayRecoveryInstructions, + recoverInstalledLaunchAgentAfterUpdate, + recoverLaunchAgentAndRecheckGatewayHealth, + shouldUseLegacyProcessRestartAfterUpdate, +} from "./update-command-service.test-support.js"; describe("resolveGatewayInstallEntrypoint", () => { it("prefers dist/index.js over dist/entry.js when both exist", async () => { diff --git a/src/commands/audit.test-support.ts b/src/commands/audit.test-support.ts new file mode 100644 index 00000000000..7ae8792e7a7 --- /dev/null +++ b/src/commands/audit.test-support.ts @@ -0,0 +1,37 @@ +import "./audit.js"; + +type AuditTestEvent = { + occurredAt: number; + kind: string; + status: string; + action: string; + direction?: string; + channel?: string; + agentId?: string; + runId?: string; + toolName?: string; +}; + +type AuditCommandTestApi = { + formatAuditRows(events: readonly AuditTestEvent[]): string[]; + parseAuditLimit(value: string | undefined): number; + parseAuditTimestamp(value: string | undefined, flag: string): number | undefined; +}; + +function getTestApi(): AuditCommandTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.auditCommandTestApi") + ] as AuditCommandTestApi; +} + +export const testApi: AuditCommandTestApi = { + formatAuditRows(events) { + return getTestApi().formatAuditRows(events); + }, + parseAuditLimit(value) { + return getTestApi().parseAuditLimit(value); + }, + parseAuditTimestamp(value, flag) { + return getTestApi().parseAuditTimestamp(value, flag); + }, +}; diff --git a/src/commands/audit.test.ts b/src/commands/audit.test.ts index de0473771c0..7b529a73667 100644 --- a/src/commands/audit.test.ts +++ b/src/commands/audit.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; -import { auditListCommand, testApi } from "./audit.js"; +import { auditListCommand } from "./audit.js"; +import { testApi } from "./audit.test-support.js"; const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 73d9dff21fd..e32adfba701 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -208,7 +208,7 @@ export async function auditListCommand( } } -export const testApi = { +const testApi = { formatAuditRows, hasMessageSpecificFilters, isUnsupportedActivityMethodError, @@ -217,3 +217,8 @@ export const testApi = { toLegacyAuditListParams, validateAuditKind, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.auditCommandTestApi")] = + testApi; +} diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts index aafc8dc9f86..3df7fb8f155 100644 --- a/src/commands/backup-shared.ts +++ b/src/commands/backup-shared.ts @@ -110,7 +110,7 @@ export function buildBackupArchivePath(archiveRoot: string, sourcePath: string): } /** Resolve a backup plan from explicit paths, deduplicating assets already covered by parents. */ -export async function resolveBackupPlanFromPaths(params: { +async function resolveBackupPlanFromPaths(params: { stateDir: string; configPath: string; oauthDir: string; @@ -250,6 +250,12 @@ export async function resolveBackupPlanFromPaths(params: { }; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.backupPlanTestApi")] = { + resolveBackupPlanFromPaths, + }; +} + function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number { const depthDelta = left.canonicalPath.length - right.canonicalPath.length; if (depthDelta !== 0) { diff --git a/src/commands/backup.test-support.ts b/src/commands/backup.test-support.ts index fd2335d87b9..84d6245f6f5 100644 --- a/src/commands/backup.test-support.ts +++ b/src/commands/backup.test-support.ts @@ -6,7 +6,50 @@ import { vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { deleteTestEnvValue } from "../test-utils/env.js"; import * as backupShared from "./backup-shared.js"; -import { resolveBackupPlanFromPaths } from "./backup-shared.js"; +import type { BackupAsset } from "./backup-shared.js"; + +type BackupPlan = { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; + included: BackupAsset[]; + skipped: Array<{ + kind: "state" | "config" | "credentials" | "workspace"; + sourcePath: string; + displayPath: string; + reason: "covered" | "missing"; + coveredBy?: string; + }>; +}; + +type ResolveBackupPlanFromPathsParams = { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs?: string[]; + includeWorkspace?: boolean; + onlyConfig?: boolean; + configInsideState?: boolean; + oauthInsideState?: boolean; + nowMs?: number; +}; + +type BackupPlanTestApi = { + resolveBackupPlanFromPaths(params: ResolveBackupPlanFromPathsParams): Promise; +}; + +function getBackupPlanTestApi(): BackupPlanTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.backupPlanTestApi") + ] as BackupPlanTestApi; +} + +export async function resolveBackupPlanFromPaths( + params: ResolveBackupPlanFromPathsParams, +): Promise { + return await getBackupPlanTestApi().resolveBackupPlanFromPaths(params); +} const backupTestMocks = vi.hoisted(() => ({ backupVerifyCommandMock: vi.fn(), diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index eccde8d6cd5..0dd3e47326d 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -12,7 +12,6 @@ import { buildBackupArchivePath, buildBackupArchiveRoot, type BackupAsset, - resolveBackupPlanFromPaths, resolveBackupPlanFromDisk, } from "./backup-shared.js"; import { @@ -21,6 +20,7 @@ import { createBackupTestRuntime, mockStateOnlyBackupPlan, resetBackupTempHome, + resolveBackupPlanFromPaths, tarCreateMock, } from "./backup.test-support.js"; diff --git a/src/commands/doctor-auth-oauth-sidecar.test-support.ts b/src/commands/doctor-auth-oauth-sidecar.test-support.ts new file mode 100644 index 00000000000..772f52034eb --- /dev/null +++ b/src/commands/doctor-auth-oauth-sidecar.test-support.ts @@ -0,0 +1,26 @@ +import type { LegacyOAuthRef } from "./doctor/shared/legacy-oauth-sidecar.js"; +import "./doctor-auth-oauth-sidecar.js"; + +type DoctorAuthOAuthSidecarTestApi = { + buildLegacyOAuthSecretAad(params: { + ref: LegacyOAuthRef; + profileId: string; + provider: string; + }): Buffer; + buildLegacyOAuthSecretKey(seed: string): Buffer; +}; + +function getTestApi(): DoctorAuthOAuthSidecarTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.doctorAuthOAuthSidecarTestApi") + ] as DoctorAuthOAuthSidecarTestApi; +} + +export const testing: DoctorAuthOAuthSidecarTestApi = { + buildLegacyOAuthSecretAad(params) { + return getTestApi().buildLegacyOAuthSecretAad(params); + }, + buildLegacyOAuthSecretKey(seed) { + return getTestApi().buildLegacyOAuthSecretKey(seed); + }, +}; diff --git a/src/commands/doctor-auth-oauth-sidecar.test.ts b/src/commands/doctor-auth-oauth-sidecar.test.ts index cbeff5b8906..1332d254f4e 100644 --- a/src/commands/doctor-auth-oauth-sidecar.test.ts +++ b/src/commands/doctor-auth-oauth-sidecar.test.ts @@ -8,7 +8,8 @@ import { createOpenClawTestState, type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; -import { testing, maybeRepairLegacyOAuthSidecarProfiles } from "./doctor-auth-oauth-sidecar.js"; +import { maybeRepairLegacyOAuthSidecarProfiles } from "./doctor-auth-oauth-sidecar.js"; +import { testing } from "./doctor-auth-oauth-sidecar.test-support.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; const states: OpenClawTestState[] = []; diff --git a/src/commands/doctor-auth-oauth-sidecar.ts b/src/commands/doctor-auth-oauth-sidecar.ts index 8271c3a17c4..ab572889b14 100644 --- a/src/commands/doctor-auth-oauth-sidecar.ts +++ b/src/commands/doctor-auth-oauth-sidecar.ts @@ -330,7 +330,13 @@ export async function maybeRepairLegacyOAuthSidecarProfiles(params: { return result; } -export const testing = { +const testing = { buildLegacyOAuthSecretAad: legacyOAuthSidecarTestUtils.buildLegacyOAuthSecretAad, buildLegacyOAuthSecretKey: legacyOAuthSidecarTestUtils.buildLegacyOAuthSecretKey, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.doctorAuthOAuthSidecarTestApi") + ] = testing; +} diff --git a/src/commands/doctor-auth.hints.test.ts b/src/commands/doctor-auth.hints.test.ts index c487b9d757a..46cd6eae1ba 100644 --- a/src/commands/doctor-auth.hints.test.ts +++ b/src/commands/doctor-auth.hints.test.ts @@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { collectAuthProfileHealthFindings, - legacyCodexProviderOverrideToHealthFinding, noteLegacyCodexProviderOverride, } from "./doctor-auth.js"; +import { legacyCodexProviderOverrideToHealthFinding } from "./doctor-auth.test-support.js"; const mocks = vi.hoisted(() => ({ ensureAuthProfileStore: vi.fn(), diff --git a/src/commands/doctor-auth.test-support.ts b/src/commands/doctor-auth.test-support.ts new file mode 100644 index 00000000000..86a220ada79 --- /dev/null +++ b/src/commands/doctor-auth.test-support.ts @@ -0,0 +1,15 @@ +import type { HealthFinding } from "../flows/health-checks.js"; +import "./doctor-auth.js"; + +type TestApi = { + legacyCodexProviderOverrideToHealthFinding(providerOverride: unknown): HealthFinding; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.doctorAuthTestApi") + ] as TestApi; +} + +export const legacyCodexProviderOverrideToHealthFinding: TestApi["legacyCodexProviderOverrideToHealthFinding"] = + (providerOverride) => getTestApi().legacyCodexProviderOverrideToHealthFinding(providerOverride); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 6704ebff7a5..4722c01ab50 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -121,9 +121,7 @@ function buildCodexProviderOverrideWarning(providerOverride: unknown): string { return lines.join("\n"); } -export function legacyCodexProviderOverrideToHealthFinding( - providerOverride: unknown, -): HealthFinding { +function legacyCodexProviderOverrideToHealthFinding(providerOverride: unknown): HealthFinding { const message = "Legacy openai-codex transport override can shadow configured Codex OAuth credentials."; const details = buildCodexProviderOverrideWarning(providerOverride); @@ -137,6 +135,12 @@ export function legacyCodexProviderOverrideToHealthFinding( }; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.doctorAuthTestApi")] = { + legacyCodexProviderOverrideToHealthFinding, + }; +} + /** Emits a warning when legacy Codex transport overrides can shadow configured Codex OAuth. */ export function noteLegacyCodexProviderOverride(cfg: OpenClawConfig): void { const providerOverride = cfg.models?.providers?.[LEGACY_CODEX_PROVIDER_ID]; diff --git a/src/commands/doctor-heartbeat-main-session-repair.test-support.ts b/src/commands/doctor-heartbeat-main-session-repair.test-support.ts new file mode 100644 index 00000000000..90f436f80d1 --- /dev/null +++ b/src/commands/doctor-heartbeat-main-session-repair.test-support.ts @@ -0,0 +1,35 @@ +import type { SessionEntry } from "../config/sessions/types.js"; +import "./doctor-heartbeat-main-session-repair.js"; + +type TranscriptHeartbeatSummary = { + inspectedMessages: number; + userMessages: number; + heartbeatUserMessages: number; + nonHeartbeatUserMessages: number; + assistantMessages: number; + heartbeatOkAssistantMessages: number; +}; + +type TestApi = { + moveHeartbeatMainSessionEntry(params: { + store: Record; + mainKey: string; + recoveredKey: string; + }): boolean; + resolveHeartbeatMainSessionRepairCandidate(params: { + entry: SessionEntry | undefined; + transcriptPath?: string; + }): { reason: "metadata" | "transcript"; summary?: TranscriptHeartbeatSummary } | null; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.doctorHeartbeatMainSessionRepairTestApi") + ] as TestApi; +} + +export const moveHeartbeatMainSessionEntry: TestApi["moveHeartbeatMainSessionEntry"] = (params) => + getTestApi().moveHeartbeatMainSessionEntry(params); + +export const resolveHeartbeatMainSessionRepairCandidate: TestApi["resolveHeartbeatMainSessionRepairCandidate"] = + (params) => getTestApi().resolveHeartbeatMainSessionRepairCandidate(params); diff --git a/src/commands/doctor-heartbeat-main-session-repair.ts b/src/commands/doctor-heartbeat-main-session-repair.ts index ba02480b4ee..128e4e301e3 100644 --- a/src/commands/doctor-heartbeat-main-session-repair.ts +++ b/src/commands/doctor-heartbeat-main-session-repair.ts @@ -119,7 +119,7 @@ function summarizeTranscriptHeartbeatMessages( * Metadata ownership is preferred, but transcript inspection catches older stores that lack the * heartbeat isolation marker while still containing no human user messages. */ -export function resolveHeartbeatMainSessionRepairCandidate(params: { +function resolveHeartbeatMainSessionRepairCandidate(params: { entry: SessionEntry | undefined; transcriptPath?: string; }): HeartbeatMainSessionRepairCandidate | null { @@ -177,7 +177,7 @@ function resolveHeartbeatMainRecoveryKey(params: { } /** Moves a poisoned main-session entry to a recovery key without overwriting existing entries. */ -export function moveHeartbeatMainSessionEntry(params: { +function moveHeartbeatMainSessionEntry(params: { store: Record; mainKey: string; recoveredKey: string; @@ -191,6 +191,15 @@ export function moveHeartbeatMainSessionEntry(params: { return true; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.doctorHeartbeatMainSessionRepairTestApi") + ] = { + moveHeartbeatMainSessionEntry, + resolveHeartbeatMainSessionRepairCandidate, + }; +} + /** * Prompts to archive a heartbeat-owned main session and clears stale TUI restore state. * diff --git a/src/commands/doctor-sandbox.test-support.ts b/src/commands/doctor-sandbox.test-support.ts new file mode 100644 index 00000000000..3041d30eb78 --- /dev/null +++ b/src/commands/doctor-sandbox.test-support.ts @@ -0,0 +1,17 @@ +import "./doctor-sandbox.js"; + +type TestApi = { + resolveSandboxScript( + scriptRel: string, + options?: { argv1?: string; cwd?: string }, + ): { scriptPath: string; cwd: string } | null; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.doctorSandboxTestApi") + ] as TestApi; +} + +export const resolveSandboxScript: TestApi["resolveSandboxScript"] = (scriptRel, options) => + getTestApi().resolveSandboxScript(scriptRel, options); diff --git a/src/commands/doctor-sandbox.test.ts b/src/commands/doctor-sandbox.test.ts index e7a61230dbb..52e609abe7e 100644 --- a/src/commands/doctor-sandbox.test.ts +++ b/src/commands/doctor-sandbox.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveSandboxScript } from "./doctor-sandbox.js"; +import { resolveSandboxScript } from "./doctor-sandbox.test-support.js"; describe("resolveSandboxScript", () => { const created: string[] = []; diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index ae39e1d227b..078ff52e2a4 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -31,7 +31,7 @@ type SandboxScriptInfo = { cwd: string; }; -export function resolveSandboxScript( +function resolveSandboxScript( scriptRel: string, options: { argv1?: string; cwd?: string } = {}, ): SandboxScriptInfo | null { @@ -53,6 +53,12 @@ export function resolveSandboxScript( return null; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.doctorSandboxTestApi")] = { + resolveSandboxScript, + }; +} + async function runSandboxScript(scriptRel: string, runtime: RuntimeEnv): Promise { const script = resolveSandboxScript(scriptRel); if (!script) { diff --git a/src/commands/doctor-session-snapshots.test-support.ts b/src/commands/doctor-session-snapshots.test-support.ts new file mode 100644 index 00000000000..536953fc04e --- /dev/null +++ b/src/commands/doctor-session-snapshots.test-support.ts @@ -0,0 +1,41 @@ +import type { SessionEntry } from "../config/sessions/types.js"; +import "./doctor-session-snapshots.js"; + +type SnapshotPathSource = + | "skillsSnapshot.prompt" + | "skillsSnapshot.resolvedSkills" + | "systemPromptReport.injectedWorkspaceFiles"; + +type TestApi = { + resolveSessionSnapshotBundledSkillsDir(params?: { + bundledSkillsDir?: string; + argv1?: string; + moduleUrl?: string; + cwd?: string; + execPath?: string; + }): string | undefined; + scanSessionStoreForStaleRuntimeSnapshotPaths(params: { + store: Record; + bundledSkillsDir: string | undefined; + pathExists?: (filePath: string) => boolean; + homeDir?: string; + env?: NodeJS.ProcessEnv; + }): Array<{ + sessionKey: string; + field: SnapshotPathSource; + cachedPath: string; + expectedPath: string; + }>; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.doctorSessionSnapshotsTestApi") + ] as TestApi; +} + +export const resolveSessionSnapshotBundledSkillsDir: TestApi["resolveSessionSnapshotBundledSkillsDir"] = + (params) => getTestApi().resolveSessionSnapshotBundledSkillsDir(params); + +export const scanSessionStoreForStaleRuntimeSnapshotPaths: TestApi["scanSessionStoreForStaleRuntimeSnapshotPaths"] = + (params) => getTestApi().scanSessionStoreForStaleRuntimeSnapshotPaths(params); diff --git a/src/commands/doctor-session-snapshots.test.ts b/src/commands/doctor-session-snapshots.test.ts index bc78b50081c..529b323328a 100644 --- a/src/commands/doctor-session-snapshots.test.ts +++ b/src/commands/doctor-session-snapshots.test.ts @@ -37,11 +37,13 @@ vi.mock("../infra/json-files.js", async (importOriginal) => { import { detectSessionSnapshotHealthIssues, noteSessionSnapshotHealth, - resolveSessionSnapshotBundledSkillsDir, - scanSessionStoreForStaleRuntimeSnapshotPaths, sessionSnapshotIssueToHealthFinding, sessionSnapshotIssueToRepairEffect, } from "./doctor-session-snapshots.js"; +import { + resolveSessionSnapshotBundledSkillsDir, + scanSessionStoreForStaleRuntimeSnapshotPaths, +} from "./doctor-session-snapshots.test-support.js"; function sessionEntry(patch: Partial): SessionEntry { return { diff --git a/src/commands/doctor-session-snapshots.ts b/src/commands/doctor-session-snapshots.ts index 37ce278a425..29326fd3df5 100644 --- a/src/commands/doctor-session-snapshots.ts +++ b/src/commands/doctor-session-snapshots.ts @@ -39,7 +39,7 @@ type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & { storePath: string; }; -export function resolveSessionSnapshotBundledSkillsDir(params?: { +function resolveSessionSnapshotBundledSkillsDir(params?: { bundledSkillsDir?: string; argv1?: string; moduleUrl?: string; @@ -259,7 +259,7 @@ function resolveMovedBundledSkillPath(params: { } /** Finds cached bundled-skill paths that point at old runtime/temp package roots. */ -export function scanSessionStoreForStaleRuntimeSnapshotPaths(params: { +function scanSessionStoreForStaleRuntimeSnapshotPaths(params: { store: Record; bundledSkillsDir: string | undefined; pathExists?: (filePath: string) => boolean; @@ -655,3 +655,12 @@ export async function noteSessionSnapshotHealth(params?: { } note(lines.join("\n"), "Session snapshots"); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.doctorSessionSnapshotsTestApi") + ] = { + resolveSessionSnapshotBundledSkillsDir, + scanSessionStoreForStaleRuntimeSnapshotPaths, + }; +} diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index f4a7e284a54..784cc25bf97 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -20,7 +20,7 @@ import { import { moveHeartbeatMainSessionEntry, resolveHeartbeatMainSessionRepairCandidate, -} from "./doctor-heartbeat-main-session-repair.js"; +} from "./doctor-heartbeat-main-session-repair.test-support.js"; import { detectStateIntegrityHealthIssues, noteStateIntegrity, diff --git a/src/commands/doctor-whatsapp-responsiveness.test-support.ts b/src/commands/doctor-whatsapp-responsiveness.test-support.ts new file mode 100644 index 00000000000..1e5c363bfe5 --- /dev/null +++ b/src/commands/doctor-whatsapp-responsiveness.test-support.ts @@ -0,0 +1,26 @@ +import "./doctor-whatsapp-responsiveness.js"; + +type LocalTuiProcess = { pid: number; command: string }; +type ProcessSignal = "SIGTERM" | "SIGKILL"; +type ProcessController = { kill(pid: number, signal: ProcessSignal | 0): boolean }; + +type TestApi = { + listLocalTuiProcesses(): LocalTuiProcess[]; + terminateLocalTuiProcesses(params: { + processes: LocalTuiProcess[]; + controller?: ProcessController; + graceMs?: number; + }): Promise<{ stopped: number[]; failed: number[] }>; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.doctorWhatsappResponsivenessTestApi") + ] as TestApi; +} + +export const listLocalTuiProcesses: TestApi["listLocalTuiProcesses"] = () => + getTestApi().listLocalTuiProcesses(); + +export const terminateLocalTuiProcesses: TestApi["terminateLocalTuiProcesses"] = (params) => + getTestApi().terminateLocalTuiProcesses(params); diff --git a/src/commands/doctor-whatsapp-responsiveness.test.ts b/src/commands/doctor-whatsapp-responsiveness.test.ts index 4e5e314fbf4..f3cf966bf22 100644 --- a/src/commands/doctor-whatsapp-responsiveness.test.ts +++ b/src/commands/doctor-whatsapp-responsiveness.test.ts @@ -16,12 +16,10 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: noteMock, })); -const { - collectWhatsappResponsivenessHealthFindings, - listLocalTuiProcesses, - noteWhatsappResponsivenessHealth, - terminateLocalTuiProcesses, -} = await import("./doctor-whatsapp-responsiveness.js"); +const { collectWhatsappResponsivenessHealthFindings, noteWhatsappResponsivenessHealth } = + await import("./doctor-whatsapp-responsiveness.js"); +const { listLocalTuiProcesses, terminateLocalTuiProcesses } = + await import("./doctor-whatsapp-responsiveness.test-support.js"); describe("doctor WhatsApp responsiveness", () => { beforeEach(() => { diff --git a/src/commands/doctor-whatsapp-responsiveness.ts b/src/commands/doctor-whatsapp-responsiveness.ts index 1cce56a766e..05b94130593 100644 --- a/src/commands/doctor-whatsapp-responsiveness.ts +++ b/src/commands/doctor-whatsapp-responsiveness.ts @@ -56,7 +56,7 @@ function parsePsPidLine(line: string): LocalTuiProcess | null { } /** Lists local OpenClaw TUI processes that can contend with gateway responsiveness. */ -export function listLocalTuiProcesses(): LocalTuiProcess[] { +function listLocalTuiProcesses(): LocalTuiProcess[] { if (process.platform === "win32") { return []; } @@ -143,7 +143,7 @@ function isProcessAlive(controller: ProcessController, pid: number): boolean { } /** Terminates local TUI processes with SIGTERM, then SIGKILL for remaining pids. */ -export async function terminateLocalTuiProcesses(params: { +async function terminateLocalTuiProcesses(params: { processes: LocalTuiProcess[]; controller?: ProcessController; graceMs?: number; @@ -182,6 +182,15 @@ export async function terminateLocalTuiProcesses(params: { return { stopped, failed }; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.doctorWhatsappResponsivenessTestApi") + ] = { + listLocalTuiProcesses, + terminateLocalTuiProcesses, + }; +} + /** Emits WhatsApp responsiveness warnings and optionally stops contending local TUI clients. */ export async function noteWhatsappResponsivenessHealth(params: { cfg: OpenClawConfig; diff --git a/src/commands/doctor/shared/codex-native-assets.test-support.ts b/src/commands/doctor/shared/codex-native-assets.test-support.ts new file mode 100644 index 00000000000..1abcee4f141 --- /dev/null +++ b/src/commands/doctor/shared/codex-native-assets.test-support.ts @@ -0,0 +1,23 @@ +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import "./codex-native-assets.js"; + +type CodexNativeAssetHit = { + kind: "skill" | "plugin" | "config" | "hooks"; + path: string; +}; + +type TestApi = { + scanCodexNativeAssets(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + }): Promise; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.codexNativeAssetsTestApi") + ] as TestApi; +} + +export const scanCodexNativeAssets: TestApi["scanCodexNativeAssets"] = (params) => + getTestApi().scanCodexNativeAssets(params); diff --git a/src/commands/doctor/shared/codex-native-assets.test.ts b/src/commands/doctor/shared/codex-native-assets.test.ts index 59b86a4ffc7..e8f7cbb5b09 100644 --- a/src/commands/doctor/shared/codex-native-assets.test.ts +++ b/src/commands/doctor/shared/codex-native-assets.test.ts @@ -4,7 +4,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { collectCodexNativeAssetInfoNotes, scanCodexNativeAssets } from "./codex-native-assets.js"; +import { collectCodexNativeAssetInfoNotes } from "./codex-native-assets.js"; +import { scanCodexNativeAssets } from "./codex-native-assets.test-support.js"; const tempRoots = new Set(); diff --git a/src/commands/doctor/shared/codex-native-assets.ts b/src/commands/doctor/shared/codex-native-assets.ts index 83f3d94c204..9100641bc17 100644 --- a/src/commands/doctor/shared/codex-native-assets.ts +++ b/src/commands/doctor/shared/codex-native-assets.ts @@ -136,7 +136,7 @@ function shouldScanCodexNativeAssets(cfg: OpenClawConfig, env: NodeJS.ProcessEnv } /** Discover personal Codex skills, plugins, config, and hooks relevant to Codex-mode agents. */ -export async function scanCodexNativeAssets(params: { +async function scanCodexNativeAssets(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): Promise { @@ -203,3 +203,9 @@ export async function collectCodexNativeAssetInfoNotes(params: { ].join("\n"), ]; } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.codexNativeAssetsTestApi")] = { + scanCodexNativeAssets, + }; +} diff --git a/src/commands/doctor/shared/codex-route-session-repair.test-support.ts b/src/commands/doctor/shared/codex-route-session-repair.test-support.ts new file mode 100644 index 00000000000..5ca9e1d293f --- /dev/null +++ b/src/commands/doctor/shared/codex-route-session-repair.test-support.ts @@ -0,0 +1,19 @@ +import type { SessionEntry } from "../../../config/sessions/types.js"; +import type { SessionRouteRepairResult } from "./codex-route-types.js"; +import "./codex-route-session-repair.js"; + +type TestApi = { + repairCodexSessionStoreRoutes(params: { + store: Record; + now?: number; + }): SessionRouteRepairResult; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.codexRouteSessionRepairTestApi") + ] as TestApi; +} + +export const repairCodexSessionStoreRoutes: TestApi["repairCodexSessionStoreRoutes"] = (params) => + getTestApi().repairCodexSessionStoreRoutes(params); diff --git a/src/commands/doctor/shared/codex-route-session-repair.ts b/src/commands/doctor/shared/codex-route-session-repair.ts index 21a33211cf6..524170192dd 100644 --- a/src/commands/doctor/shared/codex-route-session-repair.ts +++ b/src/commands/doctor/shared/codex-route-session-repair.ts @@ -101,7 +101,7 @@ function repairProviderlessCodexSessionOverride(entry: SessionEntry): boolean { } /** Rewrite stale Codex model/provider/session runtime fields inside one session store object. */ -export function repairCodexSessionStoreRoutes(params: { +function repairCodexSessionStoreRoutes(params: { store: Record; now?: number; }): SessionRouteRepairResult { @@ -139,6 +139,12 @@ export function repairCodexSessionStoreRoutes(params: { }; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.codexRouteSessionRepairTestApi") + ] = { repairCodexSessionStoreRoutes }; +} + function scanCodexSessionStoreRoutes(store: Record): string[] { return Object.entries(store).flatMap(([sessionKey, entry]) => { if (!entry || isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 754ec345d57..fa39df1ba78 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -33,11 +33,8 @@ vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => loadInstalledPluginIndex: mocks.loadInstalledPluginIndex, })); -import { - collectCodexRouteWarnings, - maybeRepairCodexRoutes, - repairCodexSessionStoreRoutes, -} from "./codex-route-warnings.js"; +import { repairCodexSessionStoreRoutes } from "./codex-route-session-repair.test-support.js"; +import { collectCodexRouteWarnings, maybeRepairCodexRoutes } from "./codex-route-warnings.js"; describe("collectCodexRouteWarnings", () => { beforeEach(() => { diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 5d82e706133..5fb1cbe4026 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -23,10 +23,7 @@ import { collectDisabledCodexPluginRouteIssues, enableCodexPluginForRequiredRoutes, } from "./codex-route-config-scan.js"; -import { - maybeRepairCodexSessionRoutes, - repairCodexSessionStoreRoutes, -} from "./codex-route-session-repair.js"; +import { maybeRepairCodexSessionRoutes } from "./codex-route-session-repair.js"; import type { CodexRouteHit, DisabledCodexPluginRouteHit, @@ -339,8 +336,4 @@ export function maybeRepairCodexRoutes(params: { }; } -export { - collectDisabledCodexPluginRouteIssues, - maybeRepairCodexSessionRoutes, - repairCodexSessionStoreRoutes, -}; +export { collectDisabledCodexPluginRouteIssues, maybeRepairCodexSessionRoutes }; diff --git a/src/commands/doctor/shared/legacy-oauth-sidecar.test-support.ts b/src/commands/doctor/shared/legacy-oauth-sidecar.test-support.ts new file mode 100644 index 00000000000..b9646f7e0cb --- /dev/null +++ b/src/commands/doctor/shared/legacy-oauth-sidecar.test-support.ts @@ -0,0 +1,15 @@ +import "./legacy-oauth-sidecar.js"; + +type TestApi = { resetKeychainOnlyMigrationHint(): void }; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.legacyOAuthSidecarInternalTestApi") + ] as TestApi; +} + +export const legacyOAuthSidecarInternalTestUtils: TestApi = { + resetKeychainOnlyMigrationHint(): void { + getTestApi().resetKeychainOnlyMigrationHint(); + }, +}; diff --git a/src/commands/doctor/shared/legacy-oauth-sidecar.test.ts b/src/commands/doctor/shared/legacy-oauth-sidecar.test.ts index 705adf1edcc..5eed0c2c9da 100644 --- a/src/commands/doctor/shared/legacy-oauth-sidecar.test.ts +++ b/src/commands/doctor/shared/legacy-oauth-sidecar.test.ts @@ -7,10 +7,10 @@ import { type OpenClawTestState, } from "../../../test-utils/openclaw-test-state.js"; import { - legacyOAuthSidecarInternalTestUtils, legacyOAuthSidecarTestUtils, loadLegacyOAuthSidecarMaterial, } from "./legacy-oauth-sidecar.js"; +import { legacyOAuthSidecarInternalTestUtils } from "./legacy-oauth-sidecar.test-support.js"; const states: OpenClawTestState[] = []; diff --git a/src/commands/doctor/shared/legacy-oauth-sidecar.ts b/src/commands/doctor/shared/legacy-oauth-sidecar.ts index d71f06f86ee..b33531c078e 100644 --- a/src/commands/doctor/shared/legacy-oauth-sidecar.ts +++ b/src/commands/doctor/shared/legacy-oauth-sidecar.ts @@ -348,12 +348,18 @@ function emitKeychainOnlyMigrationHintOnce(profileId: string): void { ); } -export const legacyOAuthSidecarInternalTestUtils = { +const legacyOAuthSidecarInternalTestUtils = { resetKeychainOnlyMigrationHint(): void { keychainOnlyMigrationHintEmitted = false; }, }; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.legacyOAuthSidecarInternalTestApi") + ] = legacyOAuthSidecarInternalTestUtils; +} + export function loadLegacyOAuthSidecarMaterial(params: { ref: LegacyOAuthRef; profileId: string; diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.test-support.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.test-support.ts new file mode 100644 index 00000000000..a1094cda91d --- /dev/null +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.test-support.ts @@ -0,0 +1,17 @@ +import "./plugin-dependency-cleanup.js"; + +type TestApi = { + collectLegacyPluginDependencyTargets( + env?: NodeJS.ProcessEnv, + options?: { packageRoot?: string | null }, + ): Promise; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.pluginDependencyCleanupTestApi") + ] as TestApi; +} + +export const collectLegacyPluginDependencyTargets: TestApi["collectLegacyPluginDependencyTargets"] = + (env, options) => getTestApi().collectLegacyPluginDependencyTargets(env, options); diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts index 4fc015e933f..12539d2832d 100644 --- a/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts @@ -4,7 +4,12 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { testing, cleanupLegacyPluginDependencyState } from "./plugin-dependency-cleanup.js"; +import { + cleanupLegacyPluginDependencyState, + detectLegacyPluginDependencyStateIssues, + legacyPluginDependencyStateIssueToHealthFinding, +} from "./plugin-dependency-cleanup.js"; +import { collectLegacyPluginDependencyTargets } from "./plugin-dependency-cleanup.test-support.js"; async function expectPathMissing(targetPath: string): Promise { try { @@ -80,7 +85,7 @@ describe("cleanupLegacyPluginDependencyState", () => { OPENCLAW_PLUGIN_STAGE_DIR: explicitStageDir, STATE_DIRECTORY: stateDirectory, }; - const targets = await testing.collectLegacyPluginDependencyTargets(env, { packageRoot }); + const targets = await collectLegacyPluginDependencyTargets(env, { packageRoot }); expect(targets).toContain(legacyRuntimeRoot); expect(targets).toContain(legacyLocalRoot); expect(targets).toContain(legacyExtensionNodeModules); @@ -90,7 +95,7 @@ describe("cleanupLegacyPluginDependencyState", () => { expect(targets).toContain(path.join(stateDirectory, "plugin-runtime-deps")); expect(targets).not.toContain(thirdPartyNodeModules); - const issues = await testing.detectLegacyPluginDependencyStateIssues({ env, packageRoot }); + const issues = await detectLegacyPluginDependencyStateIssues({ env, packageRoot }); expect(issues).toContainEqual({ kind: "legacy-plugin-dependency-state", path: legacyRuntimeRoot, @@ -154,7 +159,7 @@ describe("cleanupLegacyPluginDependencyState", () => { await fs.mkdir(legacyRuntimeRoot, { recursive: true }); await fs.mkdir(packageRoot, { recursive: true }); - const [issue] = await testing.detectLegacyPluginDependencyStateIssues({ + const [issue] = await detectLegacyPluginDependencyStateIssues({ env: { OPENCLAW_STATE_DIR: stateDir }, packageRoot, }); @@ -164,9 +169,7 @@ describe("cleanupLegacyPluginDependencyState", () => { path: legacyRuntimeRoot, }); expect( - testing.legacyPluginDependencyStateIssueToHealthFinding( - expectDefined(issue, "issue test invariant"), - ), + legacyPluginDependencyStateIssueToHealthFinding(expectDefined(issue, "issue test invariant")), ).toEqual({ checkId: "core/doctor/legacy-plugin-dependencies", severity: "warning", @@ -242,7 +245,7 @@ describe("cleanupLegacyPluginDependencyState", () => { await fs.writeFile(path.join(externalPlugin, ".openclaw-runtime-deps.json"), "{}"); await fs.symlink(externalPlugin, linkedPlugin, "dir"); - const targets = await testing.collectLegacyPluginDependencyTargets( + const targets = await collectLegacyPluginDependencyTargets( { OPENCLAW_STATE_DIR: stateDir }, { packageRoot }, ); diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.ts index 73bec0d655a..83b4cd127a8 100644 --- a/src/commands/doctor/shared/plugin-dependency-cleanup.ts +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.ts @@ -475,8 +475,8 @@ export async function cleanupLegacyPluginDependencyState(params: { return { changes, warnings }; } -export const testing = { - collectLegacyPluginDependencyTargets, - detectLegacyPluginDependencyStateIssues, - legacyPluginDependencyStateIssueToHealthFinding, -}; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.pluginDependencyCleanupTestApi") + ] = { collectLegacyPluginDependencyTargets }; +} diff --git a/src/commands/doctor/shared/stale-auth-order.test-support.ts b/src/commands/doctor/shared/stale-auth-order.test-support.ts new file mode 100644 index 00000000000..311e7e9bf32 --- /dev/null +++ b/src/commands/doctor/shared/stale-auth-order.test-support.ts @@ -0,0 +1,22 @@ +import type { AuthProfileStore } from "../../../agents/auth-profiles/types.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import "./stale-auth-order.js"; + +type TestApi = { + repairStaleConfiguredAuthOrders(params: { + cfg: OpenClawConfig; + stores: readonly AuthProfileStore[]; + activeStores?: readonly AuthProfileStore[]; + runtimeProfileIds?: ReadonlySet; + }): { config: OpenClawConfig; changes: string[] }; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.staleAuthOrderTestApi") + ] as TestApi; +} + +export const repairStaleConfiguredAuthOrders: TestApi["repairStaleConfiguredAuthOrders"] = ( + params, +) => getTestApi().repairStaleConfiguredAuthOrders(params); diff --git a/src/commands/doctor/shared/stale-auth-order.test.ts b/src/commands/doctor/shared/stale-auth-order.test.ts index 52a8430f306..a4089583256 100644 --- a/src/commands/doctor/shared/stale-auth-order.test.ts +++ b/src/commands/doctor/shared/stale-auth-order.test.ts @@ -27,8 +27,8 @@ import { closeOpenClawStateDatabaseForTest } from "../../../state/openclaw-state import { collectStaleConfiguredAuthOrderWarnings, maybeRepairStaleConfiguredAuthOrders, - repairStaleConfiguredAuthOrders, } from "./stale-auth-order.js"; +import { repairStaleConfiguredAuthOrders } from "./stale-auth-order.test-support.js"; const pluginMetadataMocks = vi.hoisted(() => { const snapshot = { diff --git a/src/commands/doctor/shared/stale-auth-order.ts b/src/commands/doctor/shared/stale-auth-order.ts index ea9eab0b60f..14cdbfa59f1 100644 --- a/src/commands/doctor/shared/stale-auth-order.ts +++ b/src/commands/doctor/shared/stale-auth-order.ts @@ -558,7 +558,7 @@ function scanStaleConfiguredAuthOrders(params: { } /** Remove provably stale config orders and restore per-agent automatic selection. */ -export function repairStaleConfiguredAuthOrders(params: { +function repairStaleConfiguredAuthOrders(params: { cfg: OpenClawConfig; stores: readonly AuthProfileStore[]; activeStores?: readonly AuthProfileStore[]; @@ -616,3 +616,9 @@ export function collectStaleConfiguredAuthOrderWarnings(params: { `- auth.order.${hit.provider} references only missing profiles while compatible stored credentials exist; run ${params.doctorFixCommand} to remove the stale override and restore automatic selection.`, ); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.staleAuthOrderTestApi")] = { + repairStaleConfiguredAuthOrders, + }; +} diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.test-support.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.test-support.ts new file mode 100644 index 00000000000..b074e18cc5c --- /dev/null +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.test-support.ts @@ -0,0 +1,34 @@ +import type { AuthProfileStore } from "../../../agents/auth-profiles/types.js"; +import "./stale-oauth-profile-shadows.js"; + +type TestApi = { + removeStaleProfilesFromStore(params: { + store: AuthProfileStore; + mainStore: AuthProfileStore; + profileIds: Set; + now: number; + }): { store: AuthProfileStore; removedProfileIds: string[] }; + repairStaleOAuthProfilesForAgent(params: { + agentDir: string; + mainStore: AuthProfileStore; + profileIds: Set; + now: number; + }): Promise< + { status: "changed"; removedProfileIds: string[] } | { status: "missing" | "unchanged" } + >; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.staleOAuthProfileShadowsTestApi") + ] as TestApi; +} + +export const testing: TestApi = { + removeStaleProfilesFromStore(params) { + return getTestApi().removeStaleProfilesFromStore(params); + }, + repairStaleOAuthProfilesForAgent(params) { + return getTestApi().repairStaleOAuthProfilesForAgent(params); + }, +}; diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts index d4e53ce1db5..d8d0238cce7 100644 --- a/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts @@ -17,11 +17,11 @@ import type { AuthProfileStore, OAuthCredential } from "../../../agents/auth-pro import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { captureEnv } from "../../../test-utils/env.js"; import { - testing, collectStaleOAuthProfileShadowWarnings, repairStaleOAuthProfileShadows, scanStaleOAuthProfileShadows, } from "./stale-oauth-profile-shadows.js"; +import { testing } from "./stale-oauth-profile-shadows.test-support.js"; function oauthCredential(overrides: Partial): OAuthCredential { return { diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts index 7a0ae4c91c7..e4d42a2c384 100644 --- a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts @@ -327,7 +327,13 @@ export async function repairStaleOAuthProfileShadows(params: { return { changes, warnings }; } -export const testing = { +const testing = { removeStaleProfilesFromStore, repairStaleOAuthProfilesForAgent, }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.staleOAuthProfileShadowsTestApi") + ] = testing; +} diff --git a/src/commands/onboard-inference.test-support.ts b/src/commands/onboard-inference.test-support.ts new file mode 100644 index 00000000000..53fee66c391 --- /dev/null +++ b/src/commands/onboard-inference.test-support.ts @@ -0,0 +1,23 @@ +import type { LocalCommandProbe } from "../system-agent/probes.js"; +import "./onboard-inference.js"; + +type TestApi = { + detectNativeCodexAppServer(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + probeLocalCommand?: ( + command: string, + args?: string[], + options?: { timeoutMs?: number }, + ) => Promise; + }): Promise; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.onboardInferenceTestApi") + ] as TestApi; +} + +export const detectNativeCodexAppServer: TestApi["detectNativeCodexAppServer"] = (options) => + getTestApi().detectNativeCodexAppServer(options); diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 30000233e41..ef0a945826d 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -7,8 +7,8 @@ import { CODEX_APP_SERVER_DEFAULT_MODEL_REF, OPENAI_API_DEFAULT_MODEL_REF, detectInferenceBackends, - detectNativeCodexAppServer, } from "./onboard-inference.js"; +import { detectNativeCodexAppServer } from "./onboard-inference.test-support.js"; function probeDeps(found: Record) { return async (command: string): Promise => ({ diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index 34430663061..20fc4cf03ea 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -155,7 +155,7 @@ async function probeCodexCommand(params: { return pathProbe; } /** Detects a native Codex App Server without coupling it to inference selection. */ -export async function detectNativeCodexAppServer( +async function detectNativeCodexAppServer( options: DetectNativeCodexAppServerOptions = {}, ): Promise { return await probeCodexCommand({ @@ -164,6 +164,12 @@ export async function detectNativeCodexAppServer( platform: options.platform ?? process.platform, }); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.onboardInferenceTestApi")] = { + detectNativeCodexAppServer, + }; +} /** * Detect usable inference backends in ladder order. Returns candidates only * for backends that exist on this machine; the first entry is the bootstrap diff --git a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts index 3a5f9393a8c..8563e1df1d3 100644 --- a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts +++ b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveGatewayHealthProbeToken } from "./onboard-non-interactive/local.js"; +import { resolveGatewayHealthProbeToken } from "./onboard-non-interactive/local.test-support.js"; async function withTempDir(run: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-health-auth-")); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index b157629eed5..af4be84ba67 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -181,13 +181,13 @@ vi.mock("../daemon/diagnostics.js", () => ({ })); let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; -let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.js").resolveInstallDaemonGatewayHealthTiming; +let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.test-support.js").resolveInstallDaemonGatewayHealthTiming; async function loadGatewayOnboardModules(): Promise { vi.resetModules(); ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); ({ resolveInstallDaemonGatewayHealthTiming } = - await import("./onboard-non-interactive/local.js")); + await import("./onboard-non-interactive/local.test-support.js")); } function getPseudoPort(base: number): number { diff --git a/src/commands/onboard-non-interactive/local.test-support.ts b/src/commands/onboard-non-interactive/local.test-support.ts new file mode 100644 index 00000000000..95271265465 --- /dev/null +++ b/src/commands/onboard-non-interactive/local.test-support.ts @@ -0,0 +1,30 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import "./local.js"; + +type GatewayHealthProbeAuth = { + token?: string; + password?: string; + unresolvedRefReason?: string; +}; + +type TestApi = { + resolveGatewayHealthProbeToken(nextConfig: OpenClawConfig): Promise; + resolveInstallDaemonGatewayHealthTiming(platform?: NodeJS.Platform): { + deadlineMs: number; + probeTimeoutMs: number; + healthCommandTimeoutMs: number; + }; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.onboardNonInteractiveLocalTestApi") + ] as TestApi; +} + +export const resolveGatewayHealthProbeToken: TestApi["resolveGatewayHealthProbeToken"] = ( + nextConfig, +) => getTestApi().resolveGatewayHealthProbeToken(nextConfig); + +export const resolveInstallDaemonGatewayHealthTiming: TestApi["resolveInstallDaemonGatewayHealthTiming"] = + (platform) => getTestApi().resolveInstallDaemonGatewayHealthTiming(platform); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index c6ef830b456..f1459292c11 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -41,9 +41,7 @@ const INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 10_000; const WINDOWS_INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 90_000; /** Returns platform-specific health timing for managed daemon installs. */ -export function resolveInstallDaemonGatewayHealthTiming( - platform: NodeJS.Platform = process.platform, -): { +function resolveInstallDaemonGatewayHealthTiming(platform: NodeJS.Platform = process.platform): { deadlineMs: number; probeTimeoutMs: number; healthCommandTimeoutMs: number; @@ -106,7 +104,7 @@ async function collectGatewayHealthFailureDiagnostics(): Promise< } /** Resolves the auth material used by the post-setup gateway health probe. */ -export async function resolveGatewayHealthProbeToken( +async function resolveGatewayHealthProbeToken( nextConfig: OpenClawConfig, ): Promise<{ token?: string; password?: string; unresolvedRefReason?: string }> { if (nextConfig.gateway?.auth?.mode === "password") { @@ -141,6 +139,15 @@ export async function resolveGatewayHealthProbeToken( return probeAuth; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.onboardNonInteractiveLocalTestApi") + ] = { + resolveGatewayHealthProbeToken, + resolveInstallDaemonGatewayHealthTiming, + }; +} + function formatGatewayHealthFailureDetail(params: { probeDetail?: string; unresolvedRefReason?: string; diff --git a/src/commands/onboard-skills.test-support.ts b/src/commands/onboard-skills.test-support.ts new file mode 100644 index 00000000000..9e5aa6431ba --- /dev/null +++ b/src/commands/onboard-skills.test-support.ts @@ -0,0 +1,21 @@ +import "./onboard-skills.js"; + +type OnboardSkillsTestApi = { + formatSkillHint(skill: { description?: string; install: Array<{ label: string }> }): string; + summarizeInstallFailure(message: string): string | undefined; +}; + +function getTestApi(): OnboardSkillsTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.onboardSkillsTestApi") + ] as OnboardSkillsTestApi; +} + +export const testing: OnboardSkillsTestApi = { + formatSkillHint(skill) { + return getTestApi().formatSkillHint(skill); + }, + summarizeInstallFailure(message) { + return getTestApi().summarizeInstallFailure(message); + }, +}; diff --git a/src/commands/onboard-skills.test.ts b/src/commands/onboard-skills.test.ts index eb4eadb6eee..e62b6a1f243 100644 --- a/src/commands/onboard-skills.test.ts +++ b/src/commands/onboard-skills.test.ts @@ -32,7 +32,8 @@ vi.mock("./onboard-helpers.js", () => ({ detectBinary: mocks.detectBinary, })); -import { setupSkills, testing } from "./onboard-skills.js"; +import { setupSkills } from "./onboard-skills.js"; +import { testing } from "./onboard-skills.test-support.js"; describe("skill onboarding text bounds", () => { it("keeps install failures and hints UTF-16 well-formed", () => { diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index f3a2c400799..76722dc44e3 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -65,7 +65,12 @@ function formatSkillHint(skill: { return combined.length > maxLen ? `${truncateUtf16Safe(combined, maxLen - 1)}…` : combined; } -export const testing = { formatSkillHint, summarizeInstallFailure }; +const testing = { formatSkillHint, summarizeInstallFailure }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.onboardSkillsTestApi")] = + testing; +} const SKIP_REASON_LABELS = { brew: "Homebrew", diff --git a/src/commands/onboarding-plugin-install.test-support.ts b/src/commands/onboarding-plugin-install.test-support.ts new file mode 100644 index 00000000000..b249412e6ad --- /dev/null +++ b/src/commands/onboarding-plugin-install.test-support.ts @@ -0,0 +1,21 @@ +import "./onboarding-plugin-install.js"; + +type OnboardingPluginInstallTestApi = { + formatInstallErrorDetail(message: string): string; + summarizeInstallError(message: string): string; +}; + +function getTestApi(): OnboardingPluginInstallTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.onboardingPluginInstallTestApi") + ] as OnboardingPluginInstallTestApi; +} + +export const testing: OnboardingPluginInstallTestApi = { + formatInstallErrorDetail(message) { + return getTestApi().formatInstallErrorDetail(message); + }, + summarizeInstallError(message) { + return getTestApi().summarizeInstallError(message); + }, +}; diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 3b999a0dcee..ef6acbdd8df 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -117,7 +117,8 @@ vi.mock("../utils/with-timeout.js", () => ({ withTimeout, })); -import { ensureOnboardingPluginInstalled, testing } from "./onboarding-plugin-install.js"; +import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js"; +import { testing } from "./onboarding-plugin-install.test-support.js"; describe("plugin install error summaries", () => { it("keeps bounded terminal text UTF-16 well-formed", () => { diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 50fbed57899..e1ebae966c9 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -560,7 +560,13 @@ function formatInstallErrorDetail(message: string): string { return `${truncateUtf16Safe(cleaned, ONBOARDING_PLUGIN_INSTALL_ERROR_MAX_CHARS - marker.length).trimEnd()}${marker}`; } -export const testing = { formatInstallErrorDetail, summarizeInstallError }; +const testing = { formatInstallErrorDetail, summarizeInstallError }; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[ + Symbol.for("openclaw.onboardingPluginInstallTestApi") + ] = testing; +} function isTimeoutError(error: unknown): boolean { return error instanceof Error && error.message === "timeout"; diff --git a/src/commands/sessions-tail.test-support.ts b/src/commands/sessions-tail.test-support.ts new file mode 100644 index 00000000000..e5d17522781 --- /dev/null +++ b/src/commands/sessions-tail.test-support.ts @@ -0,0 +1,15 @@ +import "./sessions-tail.js"; + +type SessionsTailTestApi = { + setSessionsTailFollowIntervalMsForTests(intervalMs?: number): void; +}; + +function getTestApi(): SessionsTailTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.sessionsTailTestApi") + ] as SessionsTailTestApi; +} + +export function setSessionsTailFollowIntervalMsForTests(intervalMs?: number): void { + getTestApi().setSessionsTailFollowIntervalMsForTests(intervalMs); +} diff --git a/src/commands/sessions-tail.test.ts b/src/commands/sessions-tail.test.ts index 76aca9818ff..870b227ef0a 100644 --- a/src/commands/sessions-tail.test.ts +++ b/src/commands/sessions-tail.test.ts @@ -15,7 +15,8 @@ import { } from "../trajectory/paths.js"; import { appendSqliteTrajectoryRuntimeEvents } from "../trajectory/runtime-store.sqlite.js"; import type { TrajectoryEvent } from "../trajectory/types.js"; -import { sessionsTailCommand, setSessionsTailFollowIntervalMsForTests } from "./sessions-tail.js"; +import { sessionsTailCommand } from "./sessions-tail.js"; +import { setSessionsTailFollowIntervalMsForTests } from "./sessions-tail.test-support.js"; const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn(() => ({})), diff --git a/src/commands/sessions-tail.ts b/src/commands/sessions-tail.ts index cad799036a5..d8d2806aadf 100644 --- a/src/commands/sessions-tail.ts +++ b/src/commands/sessions-tail.ts @@ -106,10 +106,16 @@ const FOLLOW_INTERVAL_MS = 1_000; let followIntervalMsForTests: number | undefined; /** Overrides the follow polling interval for tests. */ -export function setSessionsTailFollowIntervalMsForTests(intervalMs?: number): void { +function setSessionsTailFollowIntervalMsForTests(intervalMs?: number): void { followIntervalMsForTests = intervalMs; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.sessionsTailTestApi")] = { + setSessionsTailFollowIntervalMsForTests, + }; +} + function resolveFollowIntervalMs(): number { return followIntervalMsForTests ?? FOLLOW_INTERVAL_MS; } diff --git a/src/commands/sessions.test-support.ts b/src/commands/sessions.test-support.ts new file mode 100644 index 00000000000..5e1db3ad27f --- /dev/null +++ b/src/commands/sessions.test-support.ts @@ -0,0 +1,17 @@ +import "./sessions.js"; + +type SessionsCommandTestApi = { + parseSessionsLimit(value: string | undefined): number; +}; + +function getTestApi(): SessionsCommandTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.sessionsCommandTestApi") + ] as SessionsCommandTestApi; +} + +export const testing: SessionsCommandTestApi = { + parseSessionsLimit(value) { + return getTestApi().parseSessionsLimit(value); + }, +}; diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 508935c6761..a6f8574e5dd 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -15,7 +15,8 @@ process.env.FORCE_COLOR = "0"; mockSessionsConfig(); -import { sessionsCommand, testing } from "./sessions.js"; +import { sessionsCommand } from "./sessions.js"; +import { testing } from "./sessions.test-support.js"; describe("sessionsCommand", () => { beforeEach(() => { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 15f491328be..ed0cc73a550 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -547,6 +547,11 @@ export async function sessionsCommand( } } -export const testing = { +const testing = { parseSessionsLimit, } as const; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.sessionsCommandTestApi")] = + testing; +} diff --git a/src/commands/status.command.test-support.ts b/src/commands/status.command.test-support.ts new file mode 100644 index 00000000000..b3c2c8e763f --- /dev/null +++ b/src/commands/status.command.test-support.ts @@ -0,0 +1,23 @@ +import type { ConnectPairingRequiredReason } from "../../packages/gateway-protocol/src/connect-error-details.js"; +import "./status.command.js"; + +type TestApi = { + resolvePairingRecoveryContext(params: { + error?: string | null; + closeReason?: string | null; + details?: unknown; + }): { + requestId: string | null; + reason: ConnectPairingRequiredReason | null; + remediationHint: string | null; + } | null; +}; + +function getTestApi(): TestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.statusCommandTestApi") + ] as TestApi; +} + +export const resolvePairingRecoveryContext: TestApi["resolvePairingRecoveryContext"] = (params) => + getTestApi().resolvePairingRecoveryContext(params); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 3ae650365b5..9dc24ca75eb 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -58,7 +58,7 @@ function loadStatusNodeModeModule() { } /** Extracts device-pairing recovery context from structured gateway errors or legacy message text. */ -export function resolvePairingRecoveryContext(params: { +function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; details?: unknown; @@ -92,6 +92,12 @@ export function resolvePairingRecoveryContext(params: { }; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.statusCommandTestApi")] = { + resolvePairingRecoveryContext, + }; +} + function normalizeStatusWrapperPath(value: string | null | undefined): string | null { const trimmed = value?.trim(); return trimmed ? trimmed : null; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 06f4ef926a7..7d79cfa7f8c 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -830,7 +830,8 @@ import { resolveStatusRuntimeSnapshot, resolveStatusUsageSummary, } from "./status-runtime-shared.ts"; -import { resolvePairingRecoveryContext, statusCommand } from "./status.command.js"; +import { statusCommand } from "./status.command.js"; +import { resolvePairingRecoveryContext } from "./status.command.test-support.js"; const runtime = { log: vi.fn(), diff --git a/src/cron/isolated-agent.delivery-awareness.test.ts b/src/cron/isolated-agent.delivery-awareness.test.ts index 9d89b4a4a45..0e11d874c7e 100644 --- a/src/cron/isolated-agent.delivery-awareness.test.ts +++ b/src/cron/isolated-agent.delivery-awareness.test.ts @@ -10,7 +10,7 @@ import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; -import { resetCompletedDirectCronDeliveriesForTests } from "./isolated-agent/delivery-dispatch.js"; +import { resetCompletedDirectCronDeliveriesForTests } from "./isolated-agent/delivery-dispatch.test-support.js"; async function writeDefaultAgentSessionStoreEntries( entries: Record>, diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 880ac2a352f..513097fa70b 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -158,10 +158,12 @@ import { buildOutboundSessionContext } from "../../infra/outbound/session-contex import { enqueueSystemEvent } from "../../infra/system-events.js"; import { dispatchCronDelivery, - getCompletedDirectCronDeliveriesCountForTests, queueCronMessageToolDeliveryAwareness, - resetCompletedDirectCronDeliveriesForTests, } from "./delivery-dispatch.js"; +import { + getCompletedDirectCronDeliveriesCountForTests, + resetCompletedDirectCronDeliveriesForTests, +} from "./delivery-dispatch.test-support.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import type { RunCronAgentTurnResult } from "./run.types.js"; import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; diff --git a/src/cron/isolated-agent/delivery-dispatch.test-support.ts b/src/cron/isolated-agent/delivery-dispatch.test-support.ts new file mode 100644 index 00000000000..b16f4514116 --- /dev/null +++ b/src/cron/isolated-agent/delivery-dispatch.test-support.ts @@ -0,0 +1,20 @@ +import "./delivery-dispatch.js"; + +type CronDeliveryDispatchTestApi = { + resetCompletedDirectCronDeliveriesForTests(): void; + getCompletedDirectCronDeliveriesCountForTests(): number; +}; + +function getTestApi(): CronDeliveryDispatchTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.cronDeliveryDispatchTestApi") + ] as CronDeliveryDispatchTestApi; +} + +export function resetCompletedDirectCronDeliveriesForTests(): void { + getTestApi().resetCompletedDirectCronDeliveriesForTests(); +} + +export function getCompletedDirectCronDeliveriesCountForTests(): number { + return getTestApi().getCompletedDirectCronDeliveriesCountForTests(); +} diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index b16f26c061e..f620ef78a9e 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -915,15 +915,23 @@ async function appendAdmittedDirectCronDeliveryTranscriptMirror(params: { } /** Clears the direct-delivery idempotency cache for deterministic tests. */ -export function resetCompletedDirectCronDeliveriesForTests() { +function resetCompletedDirectCronDeliveriesForTests() { COMPLETED_DIRECT_CRON_DELIVERIES.clear(); } /** Returns the direct-delivery idempotency cache size for tests. */ -export function getCompletedDirectCronDeliveriesCountForTests(): number { +function getCompletedDirectCronDeliveriesCountForTests(): number { return COMPLETED_DIRECT_CRON_DELIVERIES.size; } +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.cronDeliveryDispatchTestApi")] = + { + resetCompletedDirectCronDeliveriesForTests, + getCompletedDirectCronDeliveriesCountForTests, + }; +} + function summarizeDirectCronDeliveryError(error: unknown): string { if (error instanceof Error) { return error.message || "error"; diff --git a/src/cron/schedule.test-support.ts b/src/cron/schedule.test-support.ts new file mode 100644 index 00000000000..d3d043aa21e --- /dev/null +++ b/src/cron/schedule.test-support.ts @@ -0,0 +1,30 @@ +import "./schedule.js"; + +type CronScheduleTestApi = { + clearCronScheduleCacheForTest(): void; + getCronScheduleCacheSizeForTest(): number; + getCronScheduleCacheMaxForTest(): number; + hasCronInCacheForTest(expr: string, tz: string): boolean; +}; + +function getTestApi(): CronScheduleTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.cronScheduleTestApi") + ] as CronScheduleTestApi; +} + +export function clearCronScheduleCacheForTest(): void { + getTestApi().clearCronScheduleCacheForTest(); +} + +export function getCronScheduleCacheSizeForTest(): number { + return getTestApi().getCronScheduleCacheSizeForTest(); +} + +export function getCronScheduleCacheMaxForTest(): number { + return getTestApi().getCronScheduleCacheMaxForTest(); +} + +export function hasCronInCacheForTest(expr: string, tz: string): boolean { + return getTestApi().hasCronInCacheForTest(expr, tz); +} diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 4ccf3c23e25..e7bf8d239d8 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -2,13 +2,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { coerceFiniteScheduleNumber, - clearCronScheduleCacheForTest, computeNextRunAtMs, computePreviousRunAtMs, +} from "./schedule.js"; +import { + clearCronScheduleCacheForTest, getCronScheduleCacheMaxForTest, getCronScheduleCacheSizeForTest, hasCronInCacheForTest, -} from "./schedule.js"; +} from "./schedule.test-support.js"; function requireTimestamp(value: number | undefined, label: string): number { if (value === undefined) { diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 38c0bd072f3..a7b9719d35c 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -131,21 +131,30 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n } /** Clears the Croner expression cache for deterministic tests. */ -export function clearCronScheduleCacheForTest(): void { +function clearCronScheduleCacheForTest(): void { cronEvalCache.clear(); } /** Returns the Croner expression cache size for tests. */ -export function getCronScheduleCacheSizeForTest(): number { +function getCronScheduleCacheSizeForTest(): number { return cronEvalCache.size; } /** Returns the Croner expression cache capacity for tests. */ -export function getCronScheduleCacheMaxForTest(): number { +function getCronScheduleCacheMaxForTest(): number { return CRON_EVAL_CACHE_MAX; } /** Returns whether an expression/timezone pair is present in the Croner cache for tests. */ -export function hasCronInCacheForTest(expr: string, tz: string): boolean { +function hasCronInCacheForTest(expr: string, tz: string): boolean { return cronEvalCache.has(`${tz}\u0000${expr}`); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.cronScheduleTestApi")] = { + clearCronScheduleCacheForTest, + getCronScheduleCacheSizeForTest, + getCronScheduleCacheMaxForTest, + hasCronInCacheForTest, + }; +} diff --git a/src/cron/service.armtimer-tight-loop.test.ts b/src/cron/service.armtimer-tight-loop.test.ts index 0d7a984f949..24ba29474f3 100644 --- a/src/cron/service.armtimer-tight-loop.test.ts +++ b/src/cron/service.armtimer-tight-loop.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createNoopLogger, createCronStoreHarness } from "./service.test-harness.js"; import { createCronServiceState } from "./service/state.js"; -import { armTimer, onTimer } from "./service/timer.js"; +import { armTimer } from "./service/timer.js"; +import { onTimer } from "./service/timer.test-support.js"; import { saveCronStore } from "./store.js"; import type { CronJob } from "./types.js"; diff --git a/src/cron/service.issue-66019-unresolved-next-run.test.ts b/src/cron/service.issue-66019-unresolved-next-run.test.ts index 956fab7338e..edf7051427f 100644 --- a/src/cron/service.issue-66019-unresolved-next-run.test.ts +++ b/src/cron/service.issue-66019-unresolved-next-run.test.ts @@ -8,7 +8,7 @@ import { } from "../../test/helpers/cron/service-regression-fixtures.js"; import * as schedule from "./schedule.js"; import { createCronServiceState } from "./service/state.js"; -import { onTimer } from "./service/timer.js"; +import { onTimer } from "./service/timer.test-support.js"; import { saveCronStore } from "./store.js"; const issue66019Fixtures = setupCronRegressionFixtures({ prefix: "cron-66019-" }); diff --git a/src/cron/service.rearm-timer-when-running.test.ts b/src/cron/service.rearm-timer-when-running.test.ts index 7f544f8fb5a..8c1c918096c 100644 --- a/src/cron/service.rearm-timer-when-running.test.ts +++ b/src/cron/service.rearm-timer-when-running.test.ts @@ -8,7 +8,7 @@ import { createRunningCronServiceState, } from "./service.test-harness.js"; import { createCronServiceState } from "./service/state.js"; -import { onTimer } from "./service/timer.js"; +import { onTimer } from "./service/timer.test-support.js"; import type { CronJob } from "./types.js"; const noopLogger = createNoopLogger(); diff --git a/src/cron/service.removal-postcommit.test.ts b/src/cron/service.removal-postcommit.test.ts index 4800c787072..debd34753b0 100644 --- a/src/cron/service.removal-postcommit.test.ts +++ b/src/cron/service.removal-postcommit.test.ts @@ -4,7 +4,8 @@ import { setupCronServiceSuite } from "./service.test-harness.js"; import { list, run } from "./service/ops.js"; import { createCronServiceState, type CronEvent, type CronServiceState } from "./service/state.js"; import { ensureLoaded } from "./service/store.js"; -import { onTimer, runMissedJobs } from "./service/timer.js"; +import { runMissedJobs } from "./service/timer.js"; +import { onTimer } from "./service/timer.test-support.js"; import * as cronStoreModule from "./store.js"; import { loadCronStore, saveCronStore } from "./store.js"; import type { CronJob } from "./types.js"; diff --git a/src/cron/service.session-reaper-in-finally.test.ts b/src/cron/service.session-reaper-in-finally.test.ts index 37e7c7c727c..05c204874cf 100644 --- a/src/cron/service.session-reaper-in-finally.test.ts +++ b/src/cron/service.session-reaper-in-finally.test.ts @@ -9,8 +9,8 @@ import { withCronServiceStateForTest, } from "./service.test-harness.js"; import { createCronServiceState } from "./service/state.js"; -import { onTimer } from "./service/timer.js"; -import { resetReaperThrottle } from "./session-reaper.js"; +import { onTimer } from "./service/timer.test-support.js"; +import { resetReaperThrottle } from "./session-reaper.test-support.js"; import { saveCronStore } from "./store.js"; import type { CronJob } from "./types.js"; diff --git a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts index ba66f1ce47d..9b718a7e70d 100644 --- a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts +++ b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts @@ -7,7 +7,7 @@ import { withCronServiceForTest, } from "./service.test-harness.js"; import { createCronServiceState } from "./service/state.js"; -import { executeJobCore } from "./service/timer.js"; +import { executeJobCore } from "./service/timer.test-support.js"; import type { CronJob } from "./types.js"; const noopLogger = createNoopLogger(); diff --git a/src/cron/service.startup-overflow-clobber.test.ts b/src/cron/service.startup-overflow-clobber.test.ts index ef80f47a5f1..34eb1817a23 100644 --- a/src/cron/service.startup-overflow-clobber.test.ts +++ b/src/cron/service.startup-overflow-clobber.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { setupCronServiceSuite } from "./service.test-harness.js"; import { start, status } from "./service/ops.js"; import { createCronServiceState } from "./service/state.js"; -import { onTimer } from "./service/timer.js"; +import { onTimer } from "./service/timer.test-support.js"; import { saveCronStore } from "./store.js"; import type { CronJob } from "./types.js"; diff --git a/src/cron/service/active-run-cancellation.test-support.ts b/src/cron/service/active-run-cancellation.test-support.ts new file mode 100644 index 00000000000..dcec4a8a1c2 --- /dev/null +++ b/src/cron/service/active-run-cancellation.test-support.ts @@ -0,0 +1,15 @@ +import "./active-run-cancellation.js"; + +type ActiveCronTaskRunTestApi = { + resetActiveCronTaskRunsForTests(): void; +}; + +function getTestApi(): ActiveCronTaskRunTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.activeCronTaskRunTestApi") + ] as ActiveCronTaskRunTestApi; +} + +export function resetActiveCronTaskRunsForTests(): void { + getTestApi().resetActiveCronTaskRunsForTests(); +} diff --git a/src/cron/service/active-run-cancellation.test.ts b/src/cron/service/active-run-cancellation.test.ts index 23c13fce0a1..1d35d6eaead 100644 --- a/src/cron/service/active-run-cancellation.test.ts +++ b/src/cron/service/active-run-cancellation.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { getSuspensionVisibleCronTaskRunCount, - resetActiveCronTaskRunsForTests, retireActiveCronTaskRunTracking, startActiveCronTaskRunSettlementGrace, trackActiveCronTaskRunSettlement, waitForActiveCronTaskRuns, } from "./active-run-cancellation.js"; +import { resetActiveCronTaskRunsForTests } from "./active-run-cancellation.test-support.js"; const CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS = 60_000; diff --git a/src/cron/service/active-run-cancellation.ts b/src/cron/service/active-run-cancellation.ts index 3d939e3174a..2646778ee13 100644 --- a/src/cron/service/active-run-cancellation.ts +++ b/src/cron/service/active-run-cancellation.ts @@ -135,7 +135,13 @@ export function cancelActiveCronTaskRun(params: { return true; } -export function resetActiveCronTaskRunsForTests(): void { +function resetActiveCronTaskRunsForTests(): void { retireActiveCronTaskRunTracking(); suspensionVisibleCronTaskRuns.clear(); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.activeCronTaskRunTestApi")] = { + resetActiveCronTaskRunsForTests, + }; +} diff --git a/src/cron/service/ops.regression.test.ts b/src/cron/service/ops.regression.test.ts index 982ecb16560..ddd31cb16a4 100644 --- a/src/cron/service/ops.regression.test.ts +++ b/src/cron/service/ops.regression.test.ts @@ -26,7 +26,7 @@ import { loadCronStore, saveCronStore } from "../store.js"; import { enqueueRun, remove, run, start } from "./ops.js"; import type { CronEvent } from "./state.js"; import { createCronServiceState } from "./state.js"; -import { onTimer } from "./timer.js"; +import { onTimer } from "./timer.test-support.js"; const FAST_TIMEOUT_SECONDS = 1; const opsRegressionFixtures = setupCronRegressionFixtures({ diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 99b2f8cf4ca..7421511f25f 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -33,20 +33,13 @@ import type { CronAgentExecutionStarted, CronJob, } from "../types.js"; -import { - cancelActiveCronTaskRun, - resetActiveCronTaskRunsForTests, -} from "./active-run-cancellation.js"; +import { cancelActiveCronTaskRun } from "./active-run-cancellation.js"; +import { resetActiveCronTaskRunsForTests } from "./active-run-cancellation.test-support.js"; import { computeJobNextRunAtMs } from "./jobs.js"; import { run as runManualCronJob } from "./ops.js"; import { createCronServiceState, type CronEvent } from "./state.js"; -import { - applyJobResult, - executeJobCore, - executeJobCoreWithTimeout, - onTimer, - runMissedJobs, -} from "./timer.js"; +import { applyJobResult, executeJobCoreWithTimeout, runMissedJobs } from "./timer.js"; +import { executeJobCore, onTimer } from "./timer.test-support.js"; const FAST_TIMEOUT_SECONDS = 1; const timerRegressionFixtures = setupCronRegressionFixtures({ diff --git a/src/cron/service/timer.test-support.ts b/src/cron/service/timer.test-support.ts new file mode 100644 index 00000000000..c702f6d8c40 --- /dev/null +++ b/src/cron/service/timer.test-support.ts @@ -0,0 +1,40 @@ +import type { CronDeliveryTrace, CronJob, CronRunOutcome, CronRunTelemetry } from "../types.js"; +import type { CronServiceState } from "./state.js"; +import type { CronTriggerEvalOutcome } from "./timer.js"; +import "./timer.js"; + +type ExecuteJobCoreResult = CronRunOutcome & + CronRunTelemetry & { + delivered?: boolean; + deliveryAttempted?: boolean; + deliveryError?: string; + delivery?: CronDeliveryTrace; + triggerEval?: CronTriggerEvalOutcome; + }; + +type CronTimerTestApi = { + executeJobCore( + state: CronServiceState, + job: CronJob, + abortSignal?: AbortSignal, + ): Promise; + onTimer(state: CronServiceState): Promise; +}; + +function getTestApi(): CronTimerTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.cronTimerTestApi") + ] as CronTimerTestApi; +} + +export function executeJobCore( + state: CronServiceState, + job: CronJob, + abortSignal?: AbortSignal, +): Promise { + return getTestApi().executeJobCore(state, job, abortSignal); +} + +export function onTimer(state: CronServiceState): Promise { + return getTestApi().onTimer(state); +} diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index 71b7c789b6c..7cf5a07be7e 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; import { setupCronServiceSuite, writeCronStoreSnapshot } from "../../cron/service.test-harness.js"; import { createCronServiceState } from "../../cron/service/state.js"; -import { executeJobCore, onTimer } from "../../cron/service/timer.js"; +import { executeJobCore, onTimer } from "../../cron/service/timer.test-support.js"; import * as cronStoreModule from "../../cron/store.js"; import { loadCronStore } from "../../cron/store.js"; import { cronStoreKey } from "../../cron/store/key.js"; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index e3c95996293..b9d9cad4915 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1277,7 +1277,7 @@ function armRunningRecheckTimer(state: CronServiceState) { } /** Handles one cron timer tick under the process-wide root work admission. */ -export async function onTimer(state: CronServiceState) { +async function onTimer(state: CronServiceState) { let admission; try { // A restart signal can be rejected after temporarily closing admission. @@ -2051,7 +2051,7 @@ async function applyStartupCatchupOutcomes( } /** Executes a cron job without mutating persisted job state. */ -export async function executeJobCore( +async function executeJobCore( state: CronServiceState, job: CronJob, abortSignal?: AbortSignal, @@ -2456,4 +2456,11 @@ export function stopTimer(state: CronServiceState) { } state.timer = null; } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.cronTimerTestApi")] = { + executeJobCore, + onTimer, + }; +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/cron/session-reaper.test-support.ts b/src/cron/session-reaper.test-support.ts new file mode 100644 index 00000000000..1ab351887e9 --- /dev/null +++ b/src/cron/session-reaper.test-support.ts @@ -0,0 +1,15 @@ +import "./session-reaper.js"; + +type CronSessionReaperTestApi = { + resetReaperThrottle(): void; +}; + +function getTestApi(): CronSessionReaperTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.cronSessionReaperTestApi") + ] as CronSessionReaperTestApi; +} + +export function resetReaperThrottle(): void { + getTestApi().resetReaperThrottle(); +} diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index 172082fe5a8..9a52bfc7dd3 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -9,7 +9,8 @@ import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js"; import { createDeferred } from "../test-utils/deferred.js"; import type { Logger } from "./service/state.js"; -import { resetReaperThrottle, sweepCronRunSessions } from "./session-reaper.js"; +import { sweepCronRunSessions } from "./session-reaper.js"; +import { resetReaperThrottle } from "./session-reaper.test-support.js"; const { listSessionEntries, patchSessionEntry, replaceSessionEntry } = sessionAccessor; diff --git a/src/cron/session-reaper.ts b/src/cron/session-reaper.ts index 76fc1bc8148..5825436a02f 100644 --- a/src/cron/session-reaper.ts +++ b/src/cron/session-reaper.ts @@ -148,6 +148,12 @@ export async function sweepCronRunSessions(params: { } /** Resets per-store reaper throttles between tests. */ -export function resetReaperThrottle(): void { +function resetReaperThrottle(): void { lastSweepAtMsByStore.clear(); } + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.cronSessionReaperTestApi")] = { + resetReaperThrottle, + }; +} diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts index bf940b0491b..4d41ab7d24b 100644 --- a/src/cron/trigger-script.test.ts +++ b/src/cron/trigger-script.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { - testing as beforeToolCallTesting, - wrapToolWithBeforeToolCallHook, -} from "../agents/agent-tools.before-tool-call.js"; +import { wrapToolWithBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js"; +import { BEFORE_TOOL_CALL_HOOK_CONTEXT } from "../agents/before-tool-call-metadata.js"; import type { CodeModeHeadlessResult } from "../agents/code-mode.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -12,6 +10,8 @@ type EvaluatorDeps = Parameters[0]; type HeadlessParams = Parameters>[0]; type PrepareParams = Parameters>[0]; +const beforeToolCallTesting = { BEFORE_TOOL_CALL_HOOK_CONTEXT }; + function completed(params: { value: unknown; output?: unknown[] }): CodeModeHeadlessResult { return { status: "completed", diff --git a/src/gateway/server-lanes.test.ts b/src/gateway/server-lanes.test.ts index dac50ccfdc7..2ab2273a18e 100644 --- a/src/gateway/server-lanes.test.ts +++ b/src/gateway/server-lanes.test.ts @@ -26,8 +26,9 @@ describe("applyGatewayLaneConcurrency", () => { vi.useRealTimers(); // Gateway startup drains the process-global suspension cleanup state. // Reset between tests so lane assertions only see this test's setup. - const { testing } = await import("../agents/session-suspension.js"); - testing.resetSessionSuspensionStateForTest(); + const { resetSessionSuspensionStateForTest } = + await import("../agents/session-suspension.test-support.js"); + resetSessionSuspensionStateForTest(); resetCommandQueueStateForTest(); }); @@ -168,8 +169,9 @@ describe("applyGatewayLaneConcurrency", () => { }); it("does not resume cleanup-held built-in lanes during live config publication", async () => { - const { testing } = await import("../agents/session-suspension.js"); - testing.seedClearedLaneResumeForTest(CommandLane.Main, { + const { seedClearedLaneResumeForTest } = + await import("../agents/session-suspension.test-support.js"); + seedClearedLaneResumeForTest(CommandLane.Main, { resumeConcurrency: 3, resumeAtMs: Date.now() + 100, }); @@ -197,8 +199,9 @@ describe("applyGatewayLaneConcurrency", () => { it("does not resume an unexpired shared nested lane during gateway startup", async () => { vi.useFakeTimers(); vi.setSystemTime(1_000); - const { testing } = await import("../agents/session-suspension.js"); - testing.seedClearedLaneResumeForTest(CommandLane.Nested, { + const { seedClearedLaneResumeForTest } = + await import("../agents/session-suspension.test-support.js"); + seedClearedLaneResumeForTest(CommandLane.Nested, { resumeConcurrency: 1, resumeAtMs: 1_100, }); diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index 8c00df1dcb5..f20cd30f4cb 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -2,10 +2,8 @@ * Tests talk realtime relay event forwarding and connection cleanup. */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - setActiveEmbeddedRun, - testing as embeddedRunTesting, -} from "../agents/embedded-agent-runner/runs.js"; +import { setActiveEmbeddedRun } from "../agents/embedded-agent-runner/runs.js"; +import { testing as embeddedRunTesting } from "../agents/embedded-agent-runner/runs.test-support.js"; import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; import type { RealtimeVoiceBridge, diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts index a02f53d9efa..92916b01cec 100644 --- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts +++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts @@ -2,10 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveNestedAgentLaneForSession } from "../agents/lanes.js"; import { resolveReplyOperationRunState } from "../auto-reply/reply/reply-operation-run-state.js"; -import { - createReplyOperation, - testing as replyRunRegistryTesting, -} from "../auto-reply/reply/reply-run-registry.js"; +import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunRegistryTesting } from "../auto-reply/reply/reply-run-registry.test-support.js"; import type { OpenClawConfig } from "../config/config.js"; import { markCronJobActive, resetCronActiveJobs } from "../cron/active-jobs.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; diff --git a/src/logging/diagnostic-stuck-session-recovery.integration.test.ts b/src/logging/diagnostic-stuck-session-recovery.integration.test.ts index 877501a2af0..ff685f16116 100644 --- a/src/logging/diagnostic-stuck-session-recovery.integration.test.ts +++ b/src/logging/diagnostic-stuck-session-recovery.integration.test.ts @@ -2,14 +2,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js"; import { - testing as embeddedRunTesting, clearActiveEmbeddedRun, setActiveEmbeddedRun, } from "../agents/embedded-agent-runner/runs.js"; -import { - testing as replyRunTesting, - createReplyOperation, -} from "../auto-reply/reply/reply-run-registry.js"; +import { testing as embeddedRunTesting } from "../agents/embedded-agent-runner/runs.test-support.js"; +import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js"; import { enqueueCommandInLane, getQueueSize, resetCommandLane } from "../process/command-queue.js"; import { resetCommandQueueStateForTest } from "../process/command-queue.test-support.js"; import { diff --git a/src/logging/redact-internal-state.ts b/src/logging/redact-internal-state.ts new file mode 100644 index 00000000000..7320c40cfeb --- /dev/null +++ b/src/logging/redact-internal-state.ts @@ -0,0 +1,22 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +type LoggingConfig = OpenClawConfig["logging"]; +type InternalLoggingConfig = NonNullable & { + [fullContextToolPayloadRedaction]: true; +}; + +const fullContextToolPayloadRedaction = Symbol("full-context-tool-payload-redaction"); + +export const fullContextToolPayloadRedactionState = { + mark(loggingConfig: LoggingConfig): InternalLoggingConfig { + return { + ...loggingConfig, + [fullContextToolPayloadRedaction]: true, + }; + }, + isMarked(loggingConfig: LoggingConfig): boolean { + return Boolean( + (loggingConfig as InternalLoggingConfig | undefined)?.[fullContextToolPayloadRedaction], + ); + }, +}; diff --git a/src/logging/redact-internal.ts b/src/logging/redact-internal.ts index b15f138e6be..90e8c378734 100644 --- a/src/logging/redact-internal.ts +++ b/src/logging/redact-internal.ts @@ -1,23 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { fullContextToolPayloadRedactionState } from "./redact-internal-state.js"; type LoggingConfig = OpenClawConfig["logging"]; -type InternalLoggingConfig = NonNullable & { - [fullContextToolPayloadRedaction]: true; -}; - -const fullContextToolPayloadRedaction = Symbol("full-context-tool-payload-redaction"); - -export function withFullContextToolPayloadRedaction( - loggingConfig: LoggingConfig, -): InternalLoggingConfig { - return { - ...loggingConfig, - [fullContextToolPayloadRedaction]: true, - }; -} export function isFullContextToolPayloadRedaction(loggingConfig: LoggingConfig): boolean { - return Boolean( - (loggingConfig as InternalLoggingConfig | undefined)?.[fullContextToolPayloadRedaction], - ); + return fullContextToolPayloadRedactionState.isMarked(loggingConfig); } diff --git a/src/logging/redact.test-support.ts b/src/logging/redact.test-support.ts new file mode 100644 index 00000000000..0a71936225d --- /dev/null +++ b/src/logging/redact.test-support.ts @@ -0,0 +1,8 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { fullContextToolPayloadRedactionState } from "./redact-internal-state.js"; + +type LoggingConfig = OpenClawConfig["logging"]; + +export function withFullContextToolPayloadRedaction(loggingConfig: LoggingConfig): LoggingConfig { + return fullContextToolPayloadRedactionState.mark(loggingConfig); +} diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index be4f3236bf9..84aa8315fb7 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -4,7 +4,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; -import { withFullContextToolPayloadRedaction } from "./redact-internal.js"; import { getDefaultRedactPatterns, redactSecrets, @@ -15,6 +14,7 @@ import { redactToolPayloadTextWithConfig, resolveRedactOptions, } from "./redact.js"; +import { withFullContextToolPayloadRedaction } from "./redact.test-support.js"; import { registerSecretValueForRedaction } from "./secret-redaction-registry.js"; import { resetSecretRedactionRegistryForTest } from "./secret-redaction-registry.test-support.js"; diff --git a/test/e2e/qa-lab/runtime/script-evidence.ts b/test/e2e/qa-lab/runtime/script-evidence.ts index 1e558fd3ee2..fcf83d64e4b 100644 --- a/test/e2e/qa-lab/runtime/script-evidence.ts +++ b/test/e2e/qa-lab/runtime/script-evidence.ts @@ -10,8 +10,8 @@ import { type QaProviderMode, } from "../../../../extensions/qa-lab/api.js"; import { readLoggingConfig } from "../../../../src/logging/config.js"; -import { withFullContextToolPayloadRedaction } from "../../../../src/logging/redact-internal.js"; import { redactToolPayloadTextWithConfig } from "../../../../src/logging/redact.js"; +import { withFullContextToolPayloadRedaction } from "../../../../src/logging/redact.test-support.js"; import { DEFAULT_CHILD_OUTPUT_TAIL_BYTES } from "../../../helpers/bounded-child-output.js"; export const DEFAULT_QA_SCRIPT_EVIDENCE_DETAILS_BYTES = 32 * 1024; diff --git a/test/scripts/check-deadcode-exports.test.ts b/test/scripts/check-deadcode-exports.test.ts index d16ddb3e2d6..f9665620448 100644 --- a/test/scripts/check-deadcode-exports.test.ts +++ b/test/scripts/check-deadcode-exports.test.ts @@ -1,9 +1,8 @@ -// Check Deadcode Exports tests cover parsing, ratcheting, and baseline emission. +// Check Deadcode Exports tests cover parsing and hard-zero enforcement. import { describe, expect, it } from "vitest"; import knipConfig from "../../config/knip.config.ts"; import { - compareUnusedExportsToBaseline, - formatUnusedExportBaseline, + checkUnusedExports, parseKnipCompactUnusedExports, parseKnipCompactUnusedExportsResult, } from "../../scripts/check-deadcode-exports.mjs"; @@ -67,59 +66,27 @@ src/noise.ts: src/noise.ts }); }); - it.each([ - { - name: "unexpected and stale entries", - actual: ["src/a.ts: kept", "src/new.ts: added"], - required: ["src/a.ts: kept", "src/old.ts: removed"], - optional: [], - expected: { unexpected: ["src/new.ts: added"], stale: ["src/old.ts: removed"] }, - }, - { - name: "optional entries present", - actual: ["src/a.ts: kept", "src/platform.ts: variant"], - required: ["src/a.ts: kept"], - optional: ["src/platform.ts: variant"], - expected: { unexpected: [], stale: [] }, - }, - { - name: "optional entries absent", - actual: ["src/a.ts: kept"], - required: ["src/a.ts: kept"], - optional: ["src/platform.ts: variant"], - expected: { unexpected: [], stale: [] }, - }, - ])("ratchets $name", ({ actual, expected, optional, required }) => { - expect(compareUnusedExportsToBaseline(actual, required, optional)).toMatchObject(expected); + it("accepts an empty compact report with zero unused exports", () => { + expect(checkUnusedExports("")).toEqual({ + ok: true, + entries: [], + message: "", + }); }); - it("rejects unsorted and duplicate required baselines", () => { + it("rejects every unused export without an allowlist", () => { expect( - compareUnusedExportsToBaseline( - ["src/a.ts: one", "src/b.ts: two"], - ["src/b.ts: two", "src/a.ts: one", "src/a.ts: one"], - ), - ).toMatchObject({ allowlistIsSorted: false, duplicateAllowedCount: 1 }); - }); - - it("emits a sorted baseline module while preserving optional entries", () => { - expect( - formatUnusedExportBaseline( - ["src/b.ts: beta", "src/a.ts: alpha", "src/a.ts: alpha", "src/platform.ts: variant"], - ["src/platform.ts: variant"], - ), - ).toBe(`// Pre-existing unused exports awaiting deletion. -// New entries fail CI. After deleting dead code, run \`pnpm deadcode:exports:update\`. -// Do not add entries to avoid fixing new findings. -export const KNIP_UNUSED_EXPORT_BASELINE = [ - "src/a.ts: alpha", - "src/b.ts: beta", -]; - -// Platform-variant findings. Allowed when present; never required. -export const KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE = [ - "src/platform.ts: variant", -]; -`); + checkUnusedExports(`Unused exports (2) +src/z.ts: zebra +src/a.ts: alpha +`), + ).toEqual({ + ok: false, + entries: ["src/a.ts: alpha", "src/z.ts: zebra"], + message: `Unused exports are not allowed: + src/a.ts: alpha + src/z.ts: zebra +Delete the exports or model their real production consumers in Knip.`, + }); }); });