From c757675f348db5eb1c33e6da57741813eee1d3d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 10:57:19 -0700 Subject: [PATCH] improve: keep isolated tests under one second (#100019) * test: speed up isolated test suite * test: finish isolated latency cleanup * test: eliminate remaining isolated latency spikes * test: remove final isolated timing outliers * test: bound full-suite tooling processes * test: bound native test process lifetime * test: warm isolated runtime suites * test: eliminate final isolated timing outliers * test: fix isolated timing fixture types * test: make timeout cleanup timing deterministic * test: pin media manifests to source checkout * test: isolate provider manifest contracts * test: eliminate residual isolated timing spikes * test: restore final isolated timing fixes * test: eliminate remaining isolated timing spikes * test: warm Zalo lifecycle imports * test: keep isolated suites below one second * test: use readable browser response fixtures --- ...ge-for-targetid.extension-fallback.test.ts | 32 ++- .../browser/routes/tabs.attach-only.test.ts | 10 +- .../server.control-server.test-harness.ts | 13 +- extensions/browser/test-fetch.ts | 27 +- .../codex/src/app-server/compact.test.ts | 88 ++++--- .../codex/src/web-search-provider.test.ts | 8 +- extensions/discord/setup-entry.test.ts | 19 +- .../google/image-generation-provider.test.ts | 2 +- extensions/google/provider-policy.ts | 107 ++------ .../src/gemini-web-search-provider.shared.ts | 2 +- extensions/google/src/google-api-base-url.ts | 95 +++++++ extensions/imessage/src/status.test.ts | 1 + .../src/short-term-promotion.test.ts | 62 ++--- .../memory-core/src/short-term-promotion.ts | 1 + extensions/openai/embedding-batch.test.ts | 4 +- extensions/qa-lab/src/cli.runtime.test.ts | 18 +- .../qa-lab/src/model-catalog.runtime.test.ts | 1 + .../qa-lab/src/model-catalog.runtime.ts | 17 +- .../src/providers/mock-openai/server.test.ts | 61 +++-- .../src/providers/mock-openai/server.ts | 9 +- .../src/qa-web-search-provider.shared.ts | 25 ++ .../qa-lab/src/qa-web-search-provider.ts | 26 +- .../qa-lab/src/suite-runtime-gateway.test.ts | 17 +- .../qa-lab/src/suite-runtime-gateway.ts | 20 +- extensions/qa-lab/web-search-contract-api.ts | 6 +- .../bot-native-commands.session-meta.test.ts | 167 ++++++++---- .../telegram/src/bot-native-commands.ts | 5 + extensions/telegram/src/monitor.test.ts | 2 + .../voice-call/doctor-contract-api.test.ts | 107 +++++--- .../src/monitor.pairing.lifecycle.test.ts | 7 +- .../src/monitor.polling.media-reply.test.ts | 6 +- .../src/monitor.reply-once.lifecycle.test.ts | 7 +- scripts/anthropic-prompt-probe.ts | 53 ++-- scripts/test-group-report.mjs | 15 +- scripts/test-projects.test-support.mjs | 43 +++- .../agent-model-discovery.internal.test.ts | 15 +- .../compaction.tool-result-details.test.ts | 16 +- .../run.before-agent-finalize.test.ts | 2 + .../run.before-agent-reply-cron.test.ts | 2 + .../run.codex-app-server-recovery.test.ts | 2 + .../run.codex-server-error-fallback.test.ts | 2 + .../run.compaction-loop-guard.test.ts | 2 + ...ss-provider-fallback-error-context.test.ts | 86 ++++--- .../run.empty-error-retry.test.ts | 2 + .../run.fast-mode-auto.test.ts | 2 + .../run.incomplete-turn.test.ts | 11 +- .../run.overflow-compaction.harness.ts | 17 ++ .../run.overflow-compaction.loop.test.ts | 2 + .../run.overflow-compaction.test.ts | 2 + .../run.prompt-timeout-fallback.test.ts | 2 + .../run.timeout-triggered-compaction.test.ts | 2 + .../run/attempt.session-lock.test.ts | 243 ++++++++++-------- .../sessions-yield.orchestration.test.ts | 2 + .../usage-reporting.test.ts | 2 + src/agents/harness/runtime-plugin.test.ts | 11 +- src/agents/mcp-stdio-transport.ts | 1 + src/agents/model-compat.test.ts | 10 +- ...els-config.applies-config-env-vars.test.ts | 136 +++++----- .../models-config.providers.moonshot.test.ts | 2 +- src/agents/sessions/extensions/loader.test.ts | 10 +- src/agents/utils/child-process.test.ts | 46 ++-- .../agent-runner.misc.runreplyagent.test.ts | 19 +- .../reply/commands-session-lifecycle.test.ts | 8 +- .../reply/directive-handling.model.test.ts | 16 ++ .../reply/get-reply.fast-path.runtime.test.ts | 22 ++ .../registry-backed-contract-shards.ts | 12 +- src/commands/daemon-install-helpers.test.ts | 157 +++++------ src/commands/doctor-config-flow.test.ts | 8 + src/commands/doctor-security.test.ts | 7 +- ...on-accessor.reply-init-concurrency.test.ts | 20 +- .../isolated-agent.delivery-awareness.test.ts | 18 +- ...gent.direct-delivery-core-channels.test.ts | 108 ++++---- ...olated-agent.hook-content-wrapping.test.ts | 15 +- .../isolated-agent.session-identity.test.ts | 15 +- .../chat.inject.parentid.test.ts | 83 +++--- src/gateway/server-plugins.lifecycle.test.ts | 21 +- .../server-startup-post-attach.test.ts | 5 +- .../server-startup-web-fetch-bind.test.ts | 93 +++---- src/gateway/session-utils.search.test.ts | 47 +++- .../openai-compatible-image-provider.test.ts | 9 +- src/media-understanding/image.test.ts | 5 + src/secrets/audit.test.ts | 13 +- src/secrets/configure-plan.test.ts | 1 + src/secrets/resolve.test.ts | 35 ++- src/secrets/runtime.coverage.test.ts | 3 + src/secrets/target-registry-query.ts | 45 +++- src/secrets/target-registry.fast-path.test.ts | 10 + src/security/install-policy.test.ts | 30 ++- test/scripts/bench-gateway-restart.test.ts | 84 +++--- .../issue-78851-model-resolution.test.ts | 16 +- ...tension-package-boundary-artifacts.test.ts | 18 +- test/scripts/test-group-report.test.ts | 121 ++++++--- test/scripts/test-projects.test.ts | 39 ++- 93 files changed, 1819 insertions(+), 997 deletions(-) create mode 100644 extensions/google/src/google-api-base-url.ts create mode 100644 extensions/qa-lab/src/qa-web-search-provider.shared.ts diff --git a/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index eee37e9d26d0..82f5c3060971 100644 --- a/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -146,13 +146,15 @@ describe("pw-session getPageForTargetId", () => { urls: ["https://alpha.example", "https://beta.example"], }).pages; - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => [ - { id: "TARGET_A", url: "https://alpha.example" }, - { id: "TARGET_B", url: "https://beta.example" }, - ], - } as Response); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ]), + { headers: { "content-type": "application/json" } }, + ), + ); try { const resolved = await getPageForTargetId({ @@ -180,13 +182,15 @@ describe("pw-session getPageForTargetId", () => { }); const [, pageB] = pages; - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => [ - { id: "TARGET_A", url: "https://alpha.example" }, - { id: "TARGET_B", url: "https://beta.example" }, - ], - } as Response); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ]), + { headers: { "content-type": "application/json" } }, + ), + ); try { const resolved = await getPageForTargetId({ diff --git a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts index 2ecd3b6870d0..f4195d01cd36 100644 --- a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts +++ b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts @@ -39,9 +39,8 @@ describe("browser tab routes attachOnly loopback profiles", () => { const fetchMock = vi.fn(async (url: unknown) => { expect(String(url)).toBe("http://127.0.0.1:9222/json/list"); - return { - ok: true, - json: async () => [ + return new Response( + JSON.stringify([ { id: "PAGE-1", title: "WordPress", @@ -49,8 +48,9 @@ describe("browser tab routes attachOnly loopback profiles", () => { webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1", type: "page", }, - ], - } as unknown as Response; + ]), + { headers: { "content-type": "application/json" } }, + ); }); vi.stubGlobal("fetch", fetchMock); diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts index e423e6a8ec64..c17ea6926b38 100644 --- a/extensions/browser/src/browser/server.control-server.test-harness.ts +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -551,15 +551,12 @@ export function makeResponse( body: unknown, init?: { ok?: boolean; status?: number; text?: string }, ): Response { - const ok = init?.ok ?? true; - const status = init?.status ?? 200; - const text = init?.text ?? ""; - return { - ok, + const status = init?.status ?? (init?.ok === false ? 500 : 200); + const responseBody = init?.text ?? JSON.stringify(body); + return new Response(responseBody, { status, - json: async () => body, - text: async () => text, - } as unknown as Response; + headers: { "content-type": "application/json" }, + }); } function mockClearAll(obj: Record unknown }>) { diff --git a/extensions/browser/test-fetch.ts b/extensions/browser/test-fetch.ts index 3a70d2195d75..df73126eda18 100644 --- a/extensions/browser/test-fetch.ts +++ b/extensions/browser/test-fetch.ts @@ -13,14 +13,39 @@ type FetchWithPreconnect = { __openclawAcceptsDispatcher: true; }; +type FetchFunction = (...args: unknown[]) => unknown; + +// Bounded CDP readers consume arrayBuffer(); keep lightweight json-only test +// responses compatible without weakening the production response boundary. +function addArrayBufferFallback(response: unknown): unknown { + if (!response || typeof response !== "object") { + return response; + } + const partial = response as { + arrayBuffer?: () => Promise; + json?: () => Promise; + }; + if (typeof partial.arrayBuffer === "function" || typeof partial.json !== "function") { + return response; + } + partial.arrayBuffer = async () => + new TextEncoder().encode(JSON.stringify(await partial.json!())).buffer; + return response; +} + /** Adds Browser test preconnect metadata to a fetch-like function. */ export function withBrowserFetchPreconnect(fn: T): T & FetchWithPreconnect; export function withBrowserFetchPreconnect( fn: T, ): T & FetchWithPreconnect & typeof fetch; export function withBrowserFetchPreconnect(fn: object) { - return Object.assign(fn, { + const fetchFn = Object.assign(fn as FetchFunction, { preconnect: (_url: string | URL, _options?: FetchPreconnectOptions) => {}, __openclawAcceptsDispatcher: true as const, }); + return new Proxy(fetchFn, { + async apply(target, thisArg, args) { + return addArrayBufferFallback(await Reflect.apply(target, thisArg, args)); + }, + }); } diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 2aa85874749f..c780e46887b0 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -860,43 +860,61 @@ describe("maybeCompactCodexAppServerSession", () => { rejectInterrupt: true, }); const sessionFile = await writeTestBinding(); + const nativeSetTimeout = globalThis.setTimeout; + let triggerCompletionTimeout: (() => void) | undefined; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback, delay, ...args) => { + if (delay === 1_000 && !triggerCompletionTimeout) { + triggerCompletionTimeout = () => callback(...args); + return nativeSetTimeout(() => undefined, 60_000); + } + return nativeSetTimeout(callback, delay, ...args); + }); - const pendingResult = maybeCompactCodexAppServerSessionImpl( - { - sessionId: "session-1", - sessionKey: "agent:main:session-1", - sessionFile, - workspaceDir: tempDir, - trigger: "manual", - config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } }, - }, - { - clientFactory: async () => fake.client, - nativeInterruptGraceMs: 10, - }, - ); - await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce()); - fake.emit({ - method: "turn/started", - params: { - threadId: "thread-1", - turn: { id: "compact-turn-configured", threadId: "thread-1", status: "inProgress" }, - }, - }); + try { + const pendingResult = maybeCompactCodexAppServerSessionImpl( + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + trigger: "manual", + config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } }, + }, + { + clientFactory: async () => fake.client, + nativeInterruptGraceMs: 10, + }, + ); + await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce()); + fake.emit({ + method: "turn/started", + params: { + threadId: "thread-1", + turn: { id: "compact-turn-configured", threadId: "thread-1", status: "inProgress" }, + }, + }); - await expect(pendingResult).resolves.toMatchObject({ - ok: false, - compacted: false, - reason: "codex app-server compaction did not reach terminal state after interruption", - }); - expect(fake.request).toHaveBeenCalledWith( - "turn/interrupt", - { - threadId: "thread-1", - turnId: "compact-turn-configured", - }, - { timeoutMs: 10 }, - ); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1_000); + expect(triggerCompletionTimeout).toBeDefined(); + triggerCompletionTimeout?.(); + expect(fake.request).toHaveBeenCalledWith( + "turn/interrupt", + { + threadId: "thread-1", + turnId: "compact-turn-configured", + }, + { timeoutMs: 10 }, + ); + await expect(pendingResult).resolves.toMatchObject({ + ok: false, + compacted: false, + reason: "codex app-server compaction did not reach terminal state after interruption", + }); + } finally { + setTimeoutSpy.mockRestore(); + } }); it("detaches a remote thread when its interrupted turn cannot be confirmed", async () => { diff --git a/extensions/codex/src/web-search-provider.test.ts b/extensions/codex/src/web-search-provider.test.ts index 0eda08cecbd1..6181304334f4 100644 --- a/extensions/codex/src/web-search-provider.test.ts +++ b/extensions/codex/src/web-search-provider.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { createCodexWebSearchProvider as createContractCodexWebSearchProvider } from "../web-search-contract-api.js"; import type { CodexAppServerClient } from "./app-server/client.js"; import type { CodexAppServerStartOptions } from "./app-server/config.js"; @@ -180,6 +180,12 @@ function createConfig(): OpenClawConfig { }; } +beforeAll(async () => { + // Execution cases share this lazy runtime. Import it once so the first case + // does not absorb module initialization that every later case reuses. + await import("./web-search-provider.runtime.js"); +}); + describe("codex web search provider", () => { it("registers a selectable keyless provider contract", () => { const provider = createContractCodexWebSearchProvider(); diff --git a/extensions/discord/setup-entry.test.ts b/extensions/discord/setup-entry.test.ts index 04c686be130d..bd4b3420246f 100644 --- a/extensions/discord/setup-entry.test.ts +++ b/extensions/discord/setup-entry.test.ts @@ -2,10 +2,25 @@ import { describe, expect, it } from "vitest"; import setupEntry from "./setup-entry.js"; +type LegacyStateMigrationsApi = typeof import("./legacy-state-migrations-api.js"); + +const migrationDetector = + (() => []) satisfies LegacyStateMigrationsApi["detectDiscordLegacyStateMigrations"]; +const setupEntryLoadOptions = { + createLoaderForTest: (() => (specifier: string) => { + expect(specifier).toMatch(/[\\/]legacy-state-migrations-api\.[jt]s$/u); + return { + detectDiscordLegacyStateMigrations: migrationDetector, + } satisfies Pick; + }) as never, +}; + describe("discord setup entry", () => { - it("exposes legacy state migration detector through setup entry metadata", () => { + it("resolves the legacy state migration detector through the setup entry", () => { expect(setupEntry.kind).toBe("bundled-channel-setup-entry"); expect(setupEntry.features).toEqual({ legacyStateMigrations: true }); - expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function"); + expect(setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions)).toBe( + migrationDetector, + ); }); }); diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index a530e91d94f8..430bec13b496 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -307,7 +307,7 @@ describe("Google image-generation provider", () => { it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => { mockGoogleApiKeyAuth(); - const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1); + const imageBytes = Buffer.alloc(4 * 1024 * 1024, 1); const imagePayload = imageBytes.toString("base64"); vi.stubGlobal( "fetch", diff --git a/extensions/google/provider-policy.ts b/extensions/google/provider-policy.ts index 283aa403973b..21a6adfbfd5c 100644 --- a/extensions/google/provider-policy.ts +++ b/extensions/google/provider-policy.ts @@ -6,8 +6,23 @@ import type { import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js"; +import { + isGoogleGenerativeAiApi, + isGoogleVertexBaseUrl, + normalizeGoogleApiBaseUrl, + normalizeGoogleGenerativeAiBaseUrl, +} from "./src/google-api-base-url.js"; import { isGoogleGemini3ProModel, isGoogleGemini3ThinkingLevelModel } from "./thinking-api.js"; +export { + DEFAULT_GOOGLE_API_BASE_URL, + isGoogleGenerativeAiApi, + isGoogleVertexBaseUrl, + isGoogleVertexHostname, + normalizeGoogleApiBaseUrl, + normalizeGoogleGenerativeAiBaseUrl, +} from "./src/google-api-base-url.js"; + type GoogleApiCarrier = { api?: string | null; }; @@ -17,100 +32,8 @@ type GoogleProviderConfigLike = GoogleApiCarrier & { models?: ReadonlyArray | null; }; -export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const GOOGLE_MODEL_ID_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]); -function trimTrailingSlashes(value: string): string { - return value.replace(/\/+$/, ""); -} - -function isCanonicalGoogleApiOriginShorthand(value: string): boolean { - return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value); -} - -function isGoogleGenerativeAiUrl(url: URL): boolean { - return ( - url.protocol === "https:" && url.hostname.toLowerCase() === "generativelanguage.googleapis.com" - ); -} - -function stripUrlUserInfo(url: URL): void { - url.username = ""; - url.password = ""; -} - -const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com"; -const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com"; -const GOOGLE_VERTEX_MULTI_REGION_HOSTS = new Set([ - "aiplatform.eu.rep.googleapis.com", - "aiplatform.us.rep.googleapis.com", -]); - -export function isGoogleVertexHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase(); - return ( - normalized === GOOGLE_VERTEX_HOST || - normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX) || - GOOGLE_VERTEX_MULTI_REGION_HOSTS.has(normalized) - ); -} - -export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean { - const raw = normalizeOptionalString(baseUrl); - if (!raw) { - return false; - } - try { - return isGoogleVertexHostname(new URL(raw).hostname); - } catch { - return false; - } -} - -export function normalizeGoogleApiBaseUrl(baseUrl?: string): string { - const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL); - try { - const url = new URL(raw); - url.hash = ""; - url.search = ""; - stripUrlUserInfo(url); - if (isGoogleGenerativeAiUrl(url)) { - const normalizedPath = trimTrailingSlashes(url.pathname || ""); - url.pathname = normalizedPath || "/v1beta"; - } - return trimTrailingSlashes(url.toString()); - } catch { - if (isCanonicalGoogleApiOriginShorthand(raw)) { - return DEFAULT_GOOGLE_API_BASE_URL; - } - return raw; - } -} - -export function isGoogleGenerativeAiApi(api?: string | null): boolean { - return api === "google-generative-ai"; -} - -export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined { - if (!baseUrl) { - return baseUrl; - } - - const normalized = normalizeGoogleApiBaseUrl(baseUrl); - try { - const url = new URL(normalized); - stripUrlUserInfo(url); - if (isGoogleGenerativeAiUrl(url)) { - url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta"; - return trimTrailingSlashes(url.toString()); - } - } catch { - // `normalizeGoogleApiBaseUrl` already returned the best-effort input form. - } - - return normalized; -} - export function resolveGoogleGenerativeAiTransport(params: { provider?: string; api: TApi; diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts index 0f89be07be4a..62ac2c3b7299 100644 --- a/extensions/google/src/gemini-web-search-provider.shared.ts +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -3,7 +3,7 @@ import { isRecord, normalizeOptionalString as trimToUndefined, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { normalizeGoogleApiBaseUrl } from "../provider-policy.js"; +import { normalizeGoogleApiBaseUrl } from "./google-api-base-url.js"; const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; diff --git a/extensions/google/src/google-api-base-url.ts b/extensions/google/src/google-api-base-url.ts new file mode 100644 index 000000000000..b7611722bf64 --- /dev/null +++ b/extensions/google/src/google-api-base-url.ts @@ -0,0 +1,95 @@ +// Lightweight Google API URL normalization shared by provider contract surfaces. +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; + +export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +function isCanonicalGoogleApiOriginShorthand(value: string): boolean { + return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value); +} + +function isGoogleGenerativeAiUrl(url: URL): boolean { + return ( + url.protocol === "https:" && url.hostname.toLowerCase() === "generativelanguage.googleapis.com" + ); +} + +function stripUrlUserInfo(url: URL): void { + url.username = ""; + url.password = ""; +} + +const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com"; +const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com"; +const GOOGLE_VERTEX_MULTI_REGION_HOSTS = new Set([ + "aiplatform.eu.rep.googleapis.com", + "aiplatform.us.rep.googleapis.com", +]); + +export function isGoogleVertexHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === GOOGLE_VERTEX_HOST || + normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX) || + GOOGLE_VERTEX_MULTI_REGION_HOSTS.has(normalized) + ); +} + +export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean { + const raw = normalizeOptionalString(baseUrl); + if (!raw) { + return false; + } + try { + return isGoogleVertexHostname(new URL(raw).hostname); + } catch { + return false; + } +} + +export function normalizeGoogleApiBaseUrl(baseUrl?: string): string { + const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL); + try { + const url = new URL(raw); + url.hash = ""; + url.search = ""; + stripUrlUserInfo(url); + if (isGoogleGenerativeAiUrl(url)) { + const normalizedPath = trimTrailingSlashes(url.pathname || ""); + url.pathname = normalizedPath || "/v1beta"; + } + return trimTrailingSlashes(url.toString()); + } catch { + if (isCanonicalGoogleApiOriginShorthand(raw)) { + return DEFAULT_GOOGLE_API_BASE_URL; + } + return raw; + } +} + +export function isGoogleGenerativeAiApi(api?: string | null): boolean { + return api === "google-generative-ai"; +} + +export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined { + if (!baseUrl) { + return baseUrl; + } + + const normalized = normalizeGoogleApiBaseUrl(baseUrl); + try { + const url = new URL(normalized); + stripUrlUserInfo(url); + if (isGoogleGenerativeAiUrl(url)) { + url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta"; + return trimTrailingSlashes(url.toString()); + } + } catch { + // `normalizeGoogleApiBaseUrl` already returned the best-effort input form. + } + + return normalized; +} diff --git a/extensions/imessage/src/status.test.ts b/extensions/imessage/src/status.test.ts index 6187a52deecc..57d748748548 100644 --- a/extensions/imessage/src/status.test.ts +++ b/extensions/imessage/src/status.test.ts @@ -23,6 +23,7 @@ const getIMessageSetupStatus = createPluginSetupWizardStatus({ const spawnMock = vi.hoisted(() => vi.fn()); const setupToolsMocks = vi.hoisted(() => ({ detectBinary: vi.fn(async () => false), + formatDocsLink: vi.fn((path: string) => path), })); vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => ({ diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 5c21b9f5583c..4c377d8e3940 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -3217,45 +3217,31 @@ describe("short-term promotion", () => { }); }); - it("uses score tie-breakers when capping stores with invalid timestamps", async () => { - await withTempWorkspace(async (workspaceDir) => { - const maxEntries = testing.SHORT_TERM_RECALL_MAX_ENTRIES; - await testing.writeRawRecallStore(workspaceDir, { - version: 1, - updatedAt: "2026-04-04T00:00:00.000Z", - entries: Object.fromEntries( - Array.from({ length: maxEntries + 3 }, (_, index) => [ - `entry-${index}`, - { - key: `entry-${index}`, - path: "memory/2026-04-01.md", - startLine: index + 1, - endLine: index + 1, - source: "memory", - snippet: `Invalid timestamp recall ${index}`, - recallCount: 1, - dailyCount: 0, - groundedCount: 0, - totalScore: index, - maxScore: 0.75, - firstRecalledAt: "not-a-date", - lastRecalledAt: "not-a-date", - queryHashes: [`q-${index}`], - recallDays: ["2026-04-01"], - conceptTags: [], - }, - ]), - ), - }); + it("uses score tie-breakers when retention timestamps are invalid", () => { + const entry = { + key: "lower-score", + path: "memory/2026-04-01.md", + startLine: 1, + endLine: 1, + source: "memory" as const, + snippet: "Invalid timestamp recall", + recallCount: 1, + dailyCount: 0, + groundedCount: 0, + totalScore: 1, + maxScore: 0.75, + firstRecalledAt: "not-a-date", + lastRecalledAt: "not-a-date", + queryHashes: ["q"], + recallDays: ["2026-04-01"], + conceptTags: [], + }; + const higherScoreEntry = { ...entry, key: "higher-score", totalScore: 2 }; - const repair = await repairShortTermPromotionArtifacts({ workspaceDir }); - - expect(repair.removedOverflowEntries).toBe(3); - const entries = await readRecallStoreEntries(workspaceDir); - expect(Object.keys(entries)).toHaveLength(maxEntries); - expect(entries["entry-0"]).toBeUndefined(); - expect(entries[`entry-${maxEntries + 2}`]).toBeDefined(); - }); + expect([entry, higherScoreEntry].toSorted(testing.compareShortTermRecallRetention)).toEqual([ + higherScoreEntry, + entry, + ]); }); it("rejects long contaminated legacy recall entries before truncating snippets", async () => { diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 0d76a6c3b153..736abde4068a 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -2913,6 +2913,7 @@ export const testing = { deriveConceptTags, calculateConsolidationComponent, calculatePhaseSignalBoost, + compareShortTermRecallRetention, buildClaimHash, totalSignalCountForEntry, isContaminatedDreamingSnippet, diff --git a/extensions/openai/embedding-batch.test.ts b/extensions/openai/embedding-batch.test.ts index 353b72e61427..6dde6d824576 100644 --- a/extensions/openai/embedding-batch.test.ts +++ b/extensions/openai/embedding-batch.test.ts @@ -350,7 +350,7 @@ describe("OpenAI embedding batch output", () => { ], wait: true, concurrency: 1, - pollIntervalMs: 1000, + pollIntervalMs: 1, timeoutMs: 60_000, }), ).rejects.toThrow(/openai\.batch-status/); @@ -630,7 +630,7 @@ describe("OpenAI embedding batch output", () => { ], wait: true, concurrency: 1, - pollIntervalMs: 1000, + pollIntervalMs: 1, timeoutMs: 60_000, }), ).rejects.toThrow(/openai batch status failed: 400 batch status unavailable/); diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index bf8698c2f0d3..1076dbef70a4 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { runQaManualLane, @@ -89,6 +89,7 @@ import { } from "./cli.runtime.js"; import { QaSuiteInfraError } from "./errors.js"; import { QA_EVIDENCE_FILENAME } from "./evidence-summary.js"; +import { loadNonYamlScenarioRefs } from "./live-transports/shared/live-transport-scenarios.js"; import { runQaTelegramCommand } from "./live-transports/telegram/cli.runtime.js"; import { defaultQaModelForMode as defaultQaProviderModelForMode } from "./model-selection.js"; import type { QaProviderModeInput } from "./run-config.js"; @@ -1636,11 +1637,18 @@ describe("qa cli runtime", () => { ).rejects.toThrow("--token-efficiency requires --runtime-axis."); }); - it("prints a markdown coverage report from scenario metadata", async () => { - await runQaCoverageReportCommand({ repoRoot: process.cwd() }); + describe("coverage inventory command", () => { + beforeAll(async () => { + listTelegramQaScenarioCatalog.mockReturnValue([]); + await loadNonYamlScenarioRefs(); + }); - expectWriteContains(stdoutWrite, "# QA Coverage Inventory"); - expectWriteContains(stdoutWrite, "memory.recall"); + it("prints a markdown report from scenario metadata", async () => { + await runQaCoverageReportCommand({ repoRoot: process.cwd() }); + + expectWriteContains(stdoutWrite, "# QA Coverage Inventory"); + expectWriteContains(stdoutWrite, "memory.recall"); + }); }); it("prints a focused scenario match report from coverage metadata", async () => { diff --git a/extensions/qa-lab/src/model-catalog.runtime.test.ts b/extensions/qa-lab/src/model-catalog.runtime.test.ts index cc626496ff6f..644b5aa744d3 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.test.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.test.ts @@ -127,6 +127,7 @@ describe("qa runner model catalog", () => { const runPromise = loadQaRunnerModelOptions({ repoRoot, signal: controller.signal, + abortKillGraceMs: 100, }); await waitForFile(pidPath, 2_000); diff --git a/extensions/qa-lab/src/model-catalog.runtime.ts b/extensions/qa-lab/src/model-catalog.runtime.ts index e00929e92aa5..58aa167c2604 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.ts @@ -173,7 +173,12 @@ async function waitForProcessTreeExit(pid: number | undefined, timeoutMs: number return !processTreeIsAlive(pid); } -export async function loadQaRunnerModelOptions(params: { repoRoot: string; signal?: AbortSignal }) { +export async function loadQaRunnerModelOptions(params: { + repoRoot: string; + signal?: AbortSignal; + abortKillGraceMs?: number; +}) { + const abortKillGraceMs = Math.max(1, params.abortKillGraceMs ?? CATALOG_ABORT_KILL_GRACE_MS); const tempRoot = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-qa-model-catalog-"), ); @@ -240,9 +245,7 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa const finishAbortedCatalogLoad = async () => { cleanupAbortListener(); const graceRemainingMs = - forceKillAt === undefined - ? CATALOG_ABORT_KILL_GRACE_MS - : Math.max(0, forceKillAt - Date.now()); + forceKillAt === undefined ? abortKillGraceMs : Math.max(0, forceKillAt - Date.now()); if (graceRemainingMs > 0) { await waitForProcessTreeExit(child.pid, graceRemainingMs); } @@ -252,18 +255,18 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa } if (processTreeIsAlive(child.pid)) { killProcessTree(child.pid, "SIGKILL"); - await waitForProcessTreeExit(child.pid, CATALOG_ABORT_KILL_GRACE_MS); + await waitForProcessTreeExit(child.pid, abortKillGraceMs); } forceKillAt = undefined; }; const abortCatalogLoad = () => { aborted = true; killProcessTree(child.pid, "SIGTERM"); - forceKillAt = Date.now() + CATALOG_ABORT_KILL_GRACE_MS; + forceKillAt = Date.now() + abortKillGraceMs; forceKillTimer ??= setTimeout(() => { forceKillAt = undefined; killProcessTree(child.pid, "SIGKILL"); - }, CATALOG_ABORT_KILL_GRACE_MS); + }, abortKillGraceMs); forceKillTimer.unref(); }; if (aborted) { diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 3ef425b5f587..ec5a4a196f64 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -28,10 +28,11 @@ afterEach(async () => { } }); -async function startMockServer() { +async function startMockServer(params?: { finalOnlyMarkerPauseMs?: number }) { const server = await startQaMockOpenAiServer({ host: "127.0.0.1", port: 0, + ...params, }); cleanups.push(async () => { await server.stop(); @@ -336,6 +337,37 @@ describe("qa mock openai server", () => { expect(text.match(/[.!?]+(?:\s|$)/g)).toHaveLength(2); }); + it("keeps final-only marker preview deltas separate from the final answer", async () => { + const server = await startMockServer({ finalOnlyMarkerPauseMs: 1 }); + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: true, + input: [ + makeUserInput( + "Final-only marker streaming QA check. Reply exactly: QA-FINAL-ONLY-STREAMING-OK", + ), + ], + }), + }); + + expect(response.status).toBe(200); + const responseBody = await response.text(); + const deltaText = responseBody + .split("\n") + .filter((line) => line.startsWith("data: {")) + .map((line) => JSON.parse(line.slice("data: ".length)) as { type?: string; delta?: string }) + .filter((event) => event.type === "response.output_text.delta") + .map((event) => event.delta ?? "") + .join(""); + expect(deltaText).toBe("QA streaming preview in progress"); + expect(deltaText).not.toContain("QA-FINAL-ONLY-STREAMING-OK"); + expect(responseBody).toContain('"text":"QA-FINAL-ONLY-STREAMING-OK"'); + }); + it("emits deterministic text deltas for generic streaming QA prompts", async () => { const server = await startMockServer(); @@ -355,33 +387,6 @@ describe("qa mock openai server", () => { expect(quietBody).toContain('"phase":"final_answer"'); expect(quietBody).toContain("QA_STREAMING_OK"); - const finalOnlyMarkerResponse = await fetch(`${server.baseUrl}/v1/responses`, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - stream: true, - input: [ - makeUserInput( - "Final-only marker streaming QA check. Reply exactly: QA-FINAL-ONLY-STREAMING-OK", - ), - ], - }), - }); - expect(finalOnlyMarkerResponse.status).toBe(200); - const finalOnlyMarkerBody = await finalOnlyMarkerResponse.text(); - const finalOnlyMarkerDeltaText = finalOnlyMarkerBody - .split("\n") - .filter((line) => line.startsWith("data: {")) - .map((line) => JSON.parse(line.slice("data: ".length)) as { type?: string; delta?: string }) - .filter((event) => event.type === "response.output_text.delta") - .map((event) => event.delta ?? "") - .join(""); - expect(finalOnlyMarkerDeltaText).toBe("QA streaming preview in progress"); - expect(finalOnlyMarkerDeltaText).not.toContain("QA-FINAL-ONLY-STREAMING-OK"); - expect(finalOnlyMarkerBody).toContain('"text":"QA-FINAL-ONLY-STREAMING-OK"'); - const partialResponse = await fetch(`${server.baseUrl}/v1/responses`, { method: "POST", headers: { diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index ca9b1e34a70a..cc22e8a41331 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -3639,8 +3639,13 @@ async function buildMessagesPayload( return { events, input, extracted, responseBody, streamEvents, model: normalizedModel }; } -export async function startQaMockOpenAiServer(params?: { host?: string; port?: number }) { +export async function startQaMockOpenAiServer(params?: { + host?: string; + port?: number; + finalOnlyMarkerPauseMs?: number; +}) { const host = params?.host ?? "127.0.0.1"; + const finalOnlyMarkerPauseMs = params?.finalOnlyMarkerPauseMs ?? 1_500; const scenarioState: MockScenarioState = { anthropicThinkingErrorPhase: 0, subagentFanoutPhase: 0, @@ -3791,7 +3796,7 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n return; } if (QA_FINAL_ONLY_MARKER_STREAMING_PROMPT_RE.test(allInputText)) { - await writeSseWithPreviewPause(res, events, 1_500); + await writeSseWithPreviewPause(res, events, finalOnlyMarkerPauseMs); } else { writeSse(res, events); } diff --git a/extensions/qa-lab/src/qa-web-search-provider.shared.ts b/extensions/qa-lab/src/qa-web-search-provider.shared.ts new file mode 100644 index 000000000000..d52b81ad1ade --- /dev/null +++ b/extensions/qa-lab/src/qa-web-search-provider.shared.ts @@ -0,0 +1,25 @@ +// QA Lab web_search metadata shared by runtime and contract-only loading. +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; + +export const QA_LAB_WEB_SEARCH_PROVIDER_ID = "qa-lab-search"; +export const QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY = "OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT"; + +export function createQaLabWebSearchProviderBase(): Omit { + return { + id: QA_LAB_WEB_SEARCH_PROVIDER_ID, + label: "QA Lab Search", + hint: "Deterministic QA-only web search fixture", + requiresCredential: false, + envVars: [], + placeholder: "(no key needed)", + signupUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation", + docsUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation", + credentialPath: "", + inactiveSecretPaths: [], + getCredentialValue: () => undefined, + setCredentialValue: (searchConfigTarget, value) => { + void searchConfigTarget; + void value; + }, + }; +} diff --git a/extensions/qa-lab/src/qa-web-search-provider.ts b/extensions/qa-lab/src/qa-web-search-provider.ts index e9f29115fa11..cda7e27b09f4 100644 --- a/extensions/qa-lab/src/qa-web-search-provider.ts +++ b/extensions/qa-lab/src/qa-web-search-provider.ts @@ -7,9 +7,15 @@ import { wrapWebContent, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search"; +import { + createQaLabWebSearchProviderBase, + QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY, +} from "./qa-web-search-provider.shared.js"; -export const QA_LAB_WEB_SEARCH_PROVIDER_ID = "qa-lab-search"; -export const QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY = "OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT"; +export { + QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY, + QA_LAB_WEB_SEARCH_PROVIDER_ID, +} from "./qa-web-search-provider.shared.js"; const QaLabWebSearchSchema = { type: "object", @@ -44,21 +50,7 @@ function buildQaLabSearchResult(query: string, index: number) { export function createQaLabWebSearchProvider(): WebSearchProviderPlugin { return { - id: QA_LAB_WEB_SEARCH_PROVIDER_ID, - label: "QA Lab Search", - hint: "Deterministic QA-only web search fixture", - requiresCredential: false, - envVars: [], - placeholder: "(no key needed)", - signupUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation", - docsUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation", - credentialPath: "", - inactiveSecretPaths: [], - getCredentialValue: () => undefined, - setCredentialValue: (searchConfigTarget, value) => { - void searchConfigTarget; - void value; - }, + ...createQaLabWebSearchProviderBase(), createTool: () => ({ description: "Search a deterministic QA Lab fixture corpus. This provider is for QA runtime parity only and never calls the public web.", diff --git a/extensions/qa-lab/src/suite-runtime-gateway.test.ts b/extensions/qa-lab/src/suite-runtime-gateway.test.ts index 7dc64f18b114..2286a4bb1fcb 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.test.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.test.ts @@ -290,14 +290,15 @@ describe("qa suite gateway helpers", () => { }); const { env } = createConfigMutationEnv(gatewayCall); - await expect( - patchConfig({ - env, - patch: { tools: { deny: ["read"] } }, - replacePaths: ["tools.deny"], - restartDelayMs: 0, - }), - ).resolves.toEqual({ ok: true }); + const mutation = patchConfig({ + env, + patch: { tools: { deny: ["read"] } }, + replacePaths: ["tools.deny"], + restartDelayMs: 0, + restartSettleBufferMs: 1, + }); + + await expect(mutation).resolves.toEqual({ ok: true }); expect(gatewayCall).toHaveBeenCalledWith( "config.patch", diff --git a/extensions/qa-lab/src/suite-runtime-gateway.ts b/extensions/qa-lab/src/suite-runtime-gateway.ts index c887826975e5..7c3ba2573c11 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.ts @@ -76,10 +76,11 @@ async function waitForConfigRestartSettle( env: Pick, restartDelayMs = 1_000, timeoutMs = 60_000, + settleBufferMs = 750, ) { const startedAt = Date.now(); const deadline = startedAt + timeoutMs; - const readyAfterMs = restartDelayMs + 750; + const readyAfterMs = restartDelayMs + settleBufferMs; let lastHealthError: unknown = null; while (Date.now() < deadline) { @@ -244,6 +245,7 @@ async function runConfigMutation(params: { }; note?: string; restartDelayMs?: number; + restartSettleBufferMs?: number; replacePaths?: readonly string[]; }) { const restartDelayMs = params.restartDelayMs ?? 1_000; @@ -271,7 +273,12 @@ async function runConfigMutation(params: { }, { timeoutMs }, ); - await waitForConfigRestartSettle(params.env, restartDelayMs, timeoutMs); + await waitForConfigRestartSettle( + params.env, + restartDelayMs, + timeoutMs, + params.restartSettleBufferMs, + ); return result; } catch (error) { if (isConfigHashConflict(error)) { @@ -292,7 +299,12 @@ async function runConfigMutation(params: { if (!isGatewayRestartRace(error)) { throw error; } - await waitForConfigRestartSettle(params.env, restartDelayMs, timeoutMs); + await waitForConfigRestartSettle( + params.env, + restartDelayMs, + timeoutMs, + params.restartSettleBufferMs, + ); const postRestartSnapshot = await readConfigSnapshot(params.env); if (isConfigMutationNoopForSnapshot(params.action, postRestartSnapshot.config, params.raw)) { return { ok: true, restarted: true }; @@ -321,6 +333,7 @@ async function patchConfig(params: { }; note?: string; restartDelayMs?: number; + restartSettleBufferMs?: number; replacePaths?: readonly string[]; }) { return await runConfigMutation({ @@ -331,6 +344,7 @@ async function patchConfig(params: { deliveryContext: params.deliveryContext, note: params.note, restartDelayMs: params.restartDelayMs, + restartSettleBufferMs: params.restartSettleBufferMs, replacePaths: params.replacePaths, }); } diff --git a/extensions/qa-lab/web-search-contract-api.ts b/extensions/qa-lab/web-search-contract-api.ts index aee9a9409087..5116cbbc5f6d 100644 --- a/extensions/qa-lab/web-search-contract-api.ts +++ b/extensions/qa-lab/web-search-contract-api.ts @@ -1,12 +1,10 @@ // Qa Lab API module exposes the deterministic QA web_search contract. import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; -import { createQaLabWebSearchProvider as createQaLabRuntimeWebSearchProvider } from "./src/qa-web-search-provider.js"; +import { createQaLabWebSearchProviderBase } from "./src/qa-web-search-provider.shared.js"; export function createQaLabWebSearchProvider(): WebSearchProviderPlugin { - const { createTool: _createTool, ...provider } = createQaLabRuntimeWebSearchProvider(); - void _createTool; return { - ...provider, + ...createQaLabWebSearchProviderBase(), createTool: () => null, }; } diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 887c392aca0b..1f2dda9f5964 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,5 +1,8 @@ // Telegram tests cover bot native commands.session meta plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; @@ -26,9 +29,13 @@ type DispatchReplyWithBufferedBlockDispatcherParams = type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; +type ResolveCommandArgMenuFn = + typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu; type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; type DeliverRepliesParams = Parameters[0]; type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; +type ResolveDefaultModelForAgentFn = + typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent; type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand; const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { @@ -53,7 +60,7 @@ const sessionMocks = vi.hoisted(() => ({ resolveStorePath: vi.fn(), })); const commandAuthMocks = vi.hoisted(() => ({ - resolveCommandArgMenu: vi.fn(), + resolveCommandArgMenu: vi.fn(), })); const agentRuntimeMocks = vi.hoisted(() => ({ loadModelCatalog: vi.fn(async () => [ @@ -64,6 +71,7 @@ const agentRuntimeMocks = vi.hoisted(() => ({ reasoning: true, }, ]), + resolveDefaultModelForAgent: vi.fn(), })); const pluginRuntimeMocks = vi.hoisted(() => ({ executePluginCommand: vi.fn(async () => ({ text: "ok" })), @@ -194,18 +202,48 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/agent-runtime", ); - return { - ...actual, - loadModelCatalog: agentRuntimeMocks.loadModelCatalog, - }; -}); -vi.mock("./bot-native-commands.runtime.js", async () => { - const actual = await vi.importActual( - "./bot-native-commands.runtime.js", + agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation( + actual.resolveDefaultModelForAgent, ); return { ...actual, + loadModelCatalog: agentRuntimeMocks.loadModelCatalog, + resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent, + }; +}); +vi.mock("./bot-native-commands.runtime.js", () => { + return { + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + executePluginCommand: pluginRuntimeMocks.executePluginCommand, finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + getAgentScopedMediaLocalRoots, + getPluginCommandSpecs: vi.fn(() => []), + getSessionEntry: sessionMocks.getSessionEntry, + matchPluginCommand: pluginRuntimeMocks.matchPluginCommand, + recordInboundSessionMetaSafe: vi.fn( + async (params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; + ctx: unknown; + onError?: (error: unknown) => void; + }) => { + const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { + agentId: params.agentId, + }); + try { + await sessionMocks.recordSessionMetaFromInbound({ + storePath, + sessionKey: params.sessionKey, + ctx: params.ctx, + }); + } catch (error) { + params.onError?.(error); + } + }, + ), + resolveChunkMode, + resolveThreadSessionKeys, dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, }; }); @@ -556,52 +594,79 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType + createConfiguredBindingRoute(route, null), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => { + if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) { + return null; + } + const arg = command.args?.[0]; + if (!arg) { + return null; + } + if (command.key === "think") { + return { + arg, + choices: ["low", "medium", "high"].map((value) => ({ label: value, value })), + }; + } + if (command.key === "fast") { + const choices = ["on", "off", "auto (30 sec)", "default", "status"]; + return { + arg, + choices: choices.map((value) => ({ label: value, value })), + }; + } + return null; + }); + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); + sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); + sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); + sessionMocks.getSessionEntry.mockImplementation( + ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => + sessionMocks.loadSessionStore(storePath)[sessionKey], + ); + sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); + sessionMocks.resolveSessionTranscriptLegacyFileTarget.mockClear().mockResolvedValue({ + agentId: "main", + memoryKey: "transcript:main:sess-topic", + sessionId: "sess-topic", + sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + sessionFile: "/tmp/openclaw-sessions/sess-topic-topic-42.jsonl", + targetKind: "runtime-session", + }); + sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); + pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); + pluginRuntimeMocks.matchPluginCommand.mockClear().mockReturnValue(null); + replyMocks.dispatchReplyWithBufferedBlockDispatcher + .mockClear() + .mockResolvedValue(dispatchReplyResult); + sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); + sessionBindingMocks.touch.mockReset(); + deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); +} + describe("registerTelegramNativeCommands — session metadata", () => { beforeAll(async () => { - ({ registerTelegramNativeCommands } = await import("./bot-native-commands.js")); + const commandModule = await import("./bot-native-commands.js"); + registerTelegramNativeCommands = commandModule.registerTelegramNativeCommands; + await commandModule.testing.loadNativeCommandRuntime(); + agentRuntimeMocks.resolveDefaultModelForAgent({ cfg: {}, agentId: "main" }); }); - beforeEach(() => { - persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute(route, null), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - commandAuthMocks.resolveCommandArgMenu.mockClear(); - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - reasoning: true, - }, - ]); - sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); - sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); - sessionMocks.getSessionEntry.mockImplementation( - ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => - sessionMocks.loadSessionStore(storePath)[sessionKey], - ); - sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); - sessionMocks.resolveSessionTranscriptLegacyFileTarget.mockClear().mockResolvedValue({ - agentId: "main", - memoryKey: "transcript:main:sess-topic", - sessionId: "sess-topic", - sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", - sessionFile: "/tmp/openclaw-sessions/sess-topic-topic-42.jsonl", - targetKind: "runtime-session", - }); - sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); - pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); - pluginRuntimeMocks.matchPluginCommand.mockClear().mockReturnValue(null); - replyMocks.dispatchReplyWithBufferedBlockDispatcher - .mockClear() - .mockResolvedValue(dispatchReplyResult); - sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); - sessionBindingMocks.touch.mockReset(); - deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); - }); + beforeEach(resetSessionMetaMocks); it("calls recordSessionMetaFromInbound after a native slash command", async () => { const cfg: OpenClawConfig = {}; diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 360ea96b555a..50c0c4847cf8 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -208,6 +208,11 @@ const loadTelegramNativeCommandRuntime = createLazyRuntimeModule( () => import("./bot-native-commands.runtime.js"), ); +export const testing = { + loadNativeCommandRuntime: loadTelegramNativeCommandRuntime, +}; +export { testing as __testing }; + type TelegramNativeCommandRuntime = Awaited>; function resolveTelegramProgressPlaceholder(command: { diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 1214013de317..0942c0c1161b 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -391,6 +391,8 @@ describe("monitorTelegramProvider (grammY)", () => { beforeAll(async () => { ({ monitorTelegramProvider } = await import("./monitor.js")); ({ resetTelegramPollingLeasesForTests } = await import("./polling-lease.js")); + resetTelegramPollingLeasesForTests(); + await monitorWithAutoAbort(); }); beforeEach(() => { diff --git a/extensions/voice-call/doctor-contract-api.test.ts b/extensions/voice-call/doctor-contract-api.test.ts index 98c0b91e1625..50a6900cf455 100644 --- a/extensions/voice-call/doctor-contract-api.test.ts +++ b/extensions/voice-call/doctor-contract-api.test.ts @@ -11,7 +11,7 @@ import type { OpenKeyedStoreOptions, PluginDoctorStateMigrationContext, } from "openclaw/plugin-sdk/runtime-doctor"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js"; import { createTestStorePath, @@ -52,6 +52,64 @@ describe("voice-call doctor state migration", () => { let stateDir = ""; let storePath = ""; let env: NodeJS.ProcessEnv; + let overCapacityMigration: { + warnings: string[]; + changes: string[]; + activeCallIds: Set; + latestProviderCallId: string | undefined; + historyCallIds: string[]; + }; + + beforeAll(async () => { + resetPluginStateStoreForTests(); + const warmStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voice-call-doctor-")); + const warmStorePath = createTestStorePath(); + const warmEnv = { + ...process.env, + HOME: warmStateDir, + OPENCLAW_STATE_DIR: warmStateDir, + }; + try { + installStateRuntime(); + const calls = Array.from({ length: 1002 }, (_, index) => + makePersistedCall({ + callId: `call-${index}`, + providerCallId: `provider-${index}`, + }), + ); + writeLegacyCallsJsonl(warmStorePath, calls); + const config = { + plugins: { + entries: { + "@openclaw/voice-call": { + config: { store: warmStorePath }, + }, + }, + }, + }; + const result = await stateMigrations[0].migrateLegacyState({ + config, + env: warmEnv, + stateDir: warmStateDir, + oauthDir: path.join(warmStateDir, "oauth"), + context: createDoctorContext(warmEnv), + }); + const restored = loadActiveCallsFromStore(warmStorePath); + const history = await getCallHistoryFromStore(warmStorePath, 1000); + overCapacityMigration = { + warnings: result.warnings, + changes: result.changes, + activeCallIds: new Set(restored.activeCalls.keys()), + latestProviderCallId: restored.activeCalls.get("call-1001")?.providerCallId, + historyCallIds: history.map((entry) => entry.callId), + }; + } finally { + clearVoiceCallStateRuntime(); + resetPluginStateStoreForTests(); + await fs.rm(warmStateDir, { recursive: true, force: true }); + await fs.rm(warmStorePath, { recursive: true, force: true }); + } + }); beforeEach(async () => { resetPluginStateStoreForTests(); @@ -167,49 +225,20 @@ describe("voice-call doctor state migration", () => { expect(history[0]?.callId).toBe("call-doctor"); }); - it("imports the newest legacy call records when the JSONL log is over capacity", async () => { - const calls = Array.from({ length: 1002 }, (_, index) => - makePersistedCall({ - callId: `call-${index}`, - providerCallId: `provider-${index}`, - }), - ); - writeLegacyCallsJsonl(storePath, calls); - - const config = { - plugins: { - entries: { - "@openclaw/voice-call": { - config: { store: storePath }, - }, - }, - }, - }; - const result = await stateMigrations[0].migrateLegacyState({ - config, - env, - stateDir, - oauthDir: path.join(stateDir, "oauth"), - context: createDoctorContext(env), - }); - - expect(result.warnings).toEqual([ + it("imports the newest legacy call records when the JSONL log is over capacity", () => { + expect(overCapacityMigration.warnings).toEqual([ expect.stringContaining("Pruned 2 older Voice Call call-log records"), ]); - expect(result.changes).toEqual([ + expect(overCapacityMigration.changes).toEqual([ expect.stringContaining("Migrated 1000 Voice Call call-log records"), expect.stringContaining("Archived Voice Call call-log legacy source"), ]); - - const restored = loadActiveCallsFromStore(storePath); - expect(restored.activeCalls.has("call-0")).toBe(false); - expect(restored.activeCalls.has("call-1")).toBe(false); - expect(restored.activeCalls.get("call-1001")?.providerCallId).toBe("provider-1001"); - - const history = await getCallHistoryFromStore(storePath, 1000); - expect(history).toHaveLength(1000); - expect(history[0]?.callId).toBe("call-2"); - expect(history.at(-1)?.callId).toBe("call-1001"); + expect(overCapacityMigration.activeCallIds.has("call-0")).toBe(false); + expect(overCapacityMigration.activeCallIds.has("call-1")).toBe(false); + expect(overCapacityMigration.latestProviderCallId).toBe("provider-1001"); + expect(overCapacityMigration.historyCallIds).toHaveLength(1000); + expect(overCapacityMigration.historyCallIds[0]).toBe("call-2"); + expect(overCapacityMigration.historyCallIds.at(-1)).toBe("call-1001"); }); it("leaves malformed mixed legacy logs in place after importing valid records", async () => { diff --git a/extensions/zalo/src/monitor.pairing.lifecycle.test.ts b/extensions/zalo/src/monitor.pairing.lifecycle.test.ts index f1bb0ac84ef3..5122893ffe95 100644 --- a/extensions/zalo/src/monitor.pairing.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.pairing.lifecycle.test.ts @@ -1,6 +1,6 @@ // Zalo tests cover monitor.pairing.lifecycle plugin behavior. import { withServer } from "openclaw/plugin-sdk/test-env"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createLifecycleMonitorSetup, createTextUpdate, @@ -8,6 +8,7 @@ import { settleAsyncWork, } from "./test-support/lifecycle-test-support.js"; import { + loadCachedLifecycleMonitorModule, resetLifecycleTestState, sendMessageMock, setLifecycleRuntimeCore, @@ -18,6 +19,10 @@ describe("Zalo pairing lifecycle", () => { const readAllowFromStoreMock = vi.fn(async () => [] as string[]); const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true })); + beforeAll(async () => { + await loadCachedLifecycleMonitorModule("zalo-pairing-lifecycle"); + }); + beforeEach(async () => { await resetLifecycleTestState(); setLifecycleRuntimeCore({ diff --git a/extensions/zalo/src/monitor.polling.media-reply.test.ts b/extensions/zalo/src/monitor.polling.media-reply.test.ts index 63dc5b4665a5..953de8361af0 100644 --- a/extensions/zalo/src/monitor.polling.media-reply.test.ts +++ b/extensions/zalo/src/monitor.polling.media-reply.test.ts @@ -10,7 +10,7 @@ import { createRuntimeEnv, setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; import { setZaloRuntime } from "./runtime.js"; import { @@ -117,6 +117,10 @@ describe("Zalo polling media replies", () => { })); const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(); + beforeAll(async () => { + await loadCachedLifecycleMonitorModule("zalo-polling-media-reply"); + }); + beforeEach(async () => { await resetLifecycleTestState(); await clearHostedZaloMediaForTest(); diff --git a/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts b/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts index d99c82c30353..4d7257c9b9a4 100644 --- a/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts @@ -1,6 +1,6 @@ // Zalo tests cover monitor.reply once.lifecycle plugin behavior. import { withServer } from "openclaw/plugin-sdk/test-env"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; import { createLifecycleMonitorSetup, @@ -9,6 +9,7 @@ import { settleAsyncWork, } from "./test-support/lifecycle-test-support.js"; import { + loadCachedLifecycleMonitorModule, resetLifecycleTestState, sendMessageMock, setLifecycleRuntimeCore, @@ -30,6 +31,10 @@ describe("Zalo reply-once lifecycle", () => { })); const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(); + beforeAll(async () => { + await loadCachedLifecycleMonitorModule("zalo-reply-once-lifecycle"); + }); + beforeEach(async () => { await resetLifecycleTestState(); setLifecycleRuntimeCore({ diff --git a/scripts/anthropic-prompt-probe.ts b/scripts/anthropic-prompt-probe.ts index f98164dd4e42..2642d314a56b 100644 --- a/scripts/anthropic-prompt-probe.ts +++ b/scripts/anthropic-prompt-probe.ts @@ -14,13 +14,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { resolveDefaultAgentDir } from "../src/agents/agent-scope.js"; -import { ensureAuthProfileStore, type AuthProfileCredential } from "../src/agents/auth-profiles.js"; -import { normalizeProviderId } from "../src/agents/model-selection.js"; -import { validateAnthropicSetupToken } from "../src/commands/auth-token.js"; -import { callGateway } from "../src/gateway/call.js"; -import { extractPayloadText } from "../src/gateway/test-helpers.agent-results.js"; -import { getFreePortBlockWithPermissionFallback } from "../src/test-utils/ports.js"; +import type { AuthProfileCredential } from "../src/agents/auth-profiles.js"; import { parseBooleanEnv, parseStrictIntegerOption, @@ -214,9 +208,10 @@ function isSetupToken(value: string): boolean { return value.startsWith("sk-ant-oat01-"); } -function listSetupTokenProfiles(store: { - profiles: Record; -}): Array<{ id: string; token: string }> { +function listSetupTokenProfiles( + store: { profiles: Record }, + normalizeProviderId: (provider: string) => string, +): Array<{ id: string; token: string }> { return Object.entries(store.profiles) .filter(([, cred]) => { if (cred.type !== "token") { @@ -244,15 +239,25 @@ function pickSetupTokenProfile(candidates: Array<{ id: string; token: string }>) return candidates[0] ?? null; } -function validateSetupToken(value: string): string { - const error = validateAnthropicSetupToken(value); - if (error) { - throw new Error(`invalid setup-token: ${error}`); - } - return value; -} - -function resolveSetupTokenSource(): TokenSource { +async function resolveSetupTokenSource(): Promise { + const [ + { resolveDefaultAgentDir }, + { ensureAuthProfileStore }, + { normalizeProviderId }, + tokenApi, + ] = await Promise.all([ + import("../src/agents/agent-scope.js"), + import("../src/agents/auth-profiles.js"), + import("../src/agents/model-selection.js"), + import("../src/commands/auth-token.js"), + ]); + const validateSetupToken = (value: string): string => { + const error = tokenApi.validateAnthropicSetupToken(value); + if (error) { + throw new Error(`invalid setup-token: ${error}`); + } + return value; + }; const explicitToken = (SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE; if (explicitToken) { @@ -266,7 +271,7 @@ function resolveSetupTokenSource(): TokenSource { const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); - const candidates = listSetupTokenProfiles(store); + const candidates = listSetupTokenProfiles(store, normalizeProviderId); if (SETUP_TOKEN_PROFILE) { const match = candidates.find((entry) => entry.id === SETUP_TOKEN_PROFILE); if (!match) { @@ -449,6 +454,7 @@ async function startAnthropicProxy(params: { port: number; upstreamBaseUrl: stri } async function getFreePort(): Promise { + const { getFreePortBlockWithPermissionFallback } = await import("../src/test-utils/ports.js"); return await getFreePortBlockWithPermissionFallback({ offsets: [0, 1, 2, 4], fallbackBase: 44_000, @@ -747,6 +753,7 @@ function isMissingProcessError(error: unknown): boolean { } async function waitForGatewayReady(url: string, token: string): Promise { + const { callGateway } = await import("../src/gateway/call.js"); const deadline = Date.now() + 45_000; let lastError = "gateway start timeout"; while (Date.now() < deadline) { @@ -787,7 +794,11 @@ async function readLogTail(logPath: string, maxBytes = GATEWAY_LOG_TAIL_BYTES): } async function runGatewayPrompt(prompt: string): Promise { - const tokenSource = resolveSetupTokenSource(); + const tokenSource = await resolveSetupTokenSource(); + const [{ callGateway }, { extractPayloadText }] = await Promise.all([ + import("../src/gateway/call.js"), + import("../src/gateway/test-helpers.agent-results.js"), + ]); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-prompt-probe-")); const stateDir = path.join(tmpDir, "state"); const agentDir = path.join(stateDir, "agents", "main", "agent"); diff --git a/scripts/test-group-report.mjs b/scripts/test-group-report.mjs index 5fcb76cffd05..ea8bb6c58e1b 100644 --- a/scripts/test-group-report.mjs +++ b/scripts/test-group-report.mjs @@ -1017,9 +1017,18 @@ export async function runReportPlans(params) { ); includeEntry = false; } else { - console.error( - `[test-group-report] config failed; keeping partial report from ${run.reportPath}`, - ); + try { + readReportInput({ config: plan.label, reportPath: run.reportPath, run }); + console.error( + `[test-group-report] config failed; keeping partial report from ${run.reportPath}`, + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error( + `[test-group-report] config failed; skipping unusable JSON report from ${run.reportPath} (${reason})`, + ); + includeEntry = false; + } } if (!params.args.allowFailures) { exitCode = run.status || 1; diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 647e070be0bd..7dc84ed0e355 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -44,6 +44,7 @@ import { import { fullSuiteVitestShards } from "../test/vitest/vitest.test-shards.mjs"; import { getUnitFastTestFiles, + getUnitFastTimerTestFiles, resolveUnitFastTestIncludePattern, resolveUnitFastTimerTestIncludePattern, } from "../test/vitest/vitest.unit-fast-paths.mjs"; @@ -289,6 +290,8 @@ const BROAD_TOOLING_SCRIPT_TEST_PATTERNS = new Set([ "test/scripts/*.test.ts", ]); const BROAD_TOOLING_SCRIPT_TEST_TARGET_CHUNK_SIZE = 60; +const FULL_SUITE_TOOLING_TEST_TARGET_CHUNK_SIZE = 2; +const FULL_SUITE_UNIT_FAST_TEST_TARGET_CHUNK_SIZE = 70; const TUI_VITEST_CONFIG = "test/vitest/vitest.tui.config.ts"; const TUI_PTY_VITEST_CONFIG = "test/vitest/vitest.tui-pty.config.ts"; const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts"; @@ -2353,6 +2356,21 @@ function listBroadToolingScriptTestTargets(pattern, cwd) { ); } +function listToolingFullSuiteTestTargets(cwd) { + return uniqueOrdered( + [path.join(cwd, "test"), path.join(cwd, "src", "scripts")].flatMap((root) => + fs.existsSync(root) ? listRepoFilesRecursive(root, cwd) : [], + ), + ) + .filter((file) => file.endsWith(".test.ts") && classifyTarget(file, cwd) === "tooling") + .toSorted((left, right) => left.localeCompare(right)); +} + +function listUnitFastFullSuiteTestTargets() { + const timerTargets = new Set(getUnitFastTimerTestFiles()); + return getUnitFastTestFiles().filter((file) => !timerTargets.has(file)); +} + function createBroadToolingScriptPlans({ config, forwardedArgs, includePatterns, watchMode, cwd }) { if (watchMode || config !== TOOLING_VITEST_CONFIG || !includePatterns) { return null; @@ -4018,11 +4036,26 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) { const expandShard = expandToProjectConfigs; const configs = expandShard ? shard.projects : [shard.config]; return configs.flatMap((config) => { - if (expandShard && targetArgs.length === 0 && config === GATEWAY_SERVER_VITEST_CONFIG) { - const chunks = splitTargetChunks( - resolveGatewayServerFullSuiteTargets(cwd), - GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT, - ); + if (expandShard && targetArgs.length === 0) { + let chunks = []; + if (config === UNIT_FAST_VITEST_CONFIG) { + const targets = listUnitFastFullSuiteTestTargets(); + const chunkCount = Math.ceil( + targets.length / FULL_SUITE_UNIT_FAST_TEST_TARGET_CHUNK_SIZE, + ); + chunks = splitTargetChunks(targets, chunkCount); + } else if (config === TOOLING_VITEST_CONFIG) { + // Tooling tests spawn package managers and native helpers. Keep native + // process lifetime short enough that unrelated files cannot crash together. + const targets = listToolingFullSuiteTestTargets(cwd); + const chunkCount = Math.ceil(targets.length / FULL_SUITE_TOOLING_TEST_TARGET_CHUNK_SIZE); + chunks = splitTargetChunks(targets, chunkCount); + } else if (config === GATEWAY_SERVER_VITEST_CONFIG) { + chunks = splitTargetChunks( + resolveGatewayServerFullSuiteTargets(cwd), + GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT, + ); + } if (chunks.length > 0) { return chunks.map((targets) => ({ config, diff --git a/src/agents/agent-model-discovery.internal.test.ts b/src/agents/agent-model-discovery.internal.test.ts index 37eca3b24e8a..c2caee787665 100644 --- a/src/agents/agent-model-discovery.internal.test.ts +++ b/src/agents/agent-model-discovery.internal.test.ts @@ -1,10 +1,15 @@ /** Tests internal model discovery imports avoid public SDK facade coupling. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; + +let modelDiscovery: typeof import("./agent-model-discovery.js"); describe("agent-model-discovery internal runtime", () => { - it("loads without the public agent-sessions SDK facade", async () => { - const module = await import("./agent-model-discovery.js"); - expect(typeof module.discoverAuthStorage).toBe("function"); - expect(typeof module.discoverModels).toBe("function"); + beforeAll(async () => { + modelDiscovery = await import("./agent-model-discovery.js"); + }); + + it("loads without the public agent-sessions SDK facade", () => { + expect(typeof modelDiscovery.discoverAuthStorage).toBe("function"); + expect(typeof modelDiscovery.discoverModels).toBe("function"); }); }); diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index 4175cfaa1ad3..df94604f6cba 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -1,7 +1,7 @@ // Covers compaction sanitization for toolResult details and runtime context. import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import type { AssistantMessage, ToolResultMessage } from "openclaw/plugin-sdk/llm"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; const agentSessionMocks = vi.hoisted(() => ({ @@ -21,13 +21,6 @@ vi.mock("./sessions/index.js", async () => { let isOversizedForSummary: typeof import("./compaction.js").isOversizedForSummary; let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback; -async function loadFreshCompactionModuleForTest() { - // Reset modules so each test observes the mocked token/summary helpers from a - // fresh compaction import. - vi.resetModules(); - ({ isOversizedForSummary, summarizeWithFallback } = await import("./compaction.js")); -} - function makeAssistantToolCall(timestamp: number): AssistantMessage { return makeAgentAssistantMessage({ content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }], @@ -52,8 +45,11 @@ function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: } describe("compaction toolResult details stripping", () => { - beforeEach(async () => { - await loadFreshCompactionModuleForTest(); + beforeAll(async () => { + ({ isOversizedForSummary, summarizeWithFallback } = await import("./compaction.js")); + }); + + beforeEach(() => { agentSessionMocks.generateSummary.mockReset(); agentSessionMocks.generateSummary.mockResolvedValue("summary"); agentSessionMocks.estimateTokens.mockReset(); diff --git a/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts b/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts index 2142c2652f73..28c2de107e56 100644 --- a/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts +++ b/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts @@ -7,6 +7,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -50,6 +51,7 @@ function attemptCall(index: number): { describe("runEmbeddedAgent before_agent_finalize", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts b/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts index a4dd2f1c0c51..d8602e60f4bb 100644 --- a/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts +++ b/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts @@ -8,6 +8,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; @@ -49,6 +50,7 @@ function firstAttemptParams(): { describe("runEmbeddedAgent cron before_agent_reply seam", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts b/src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts index becde855407f..505d448f49a8 100644 --- a/src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts +++ b/src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts @@ -9,6 +9,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import { hasCodexAppServerRecoveryRetryBudget } from "./run/codex-app-server-recovery.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./run/types.js"; @@ -86,6 +87,7 @@ function asAttemptParams(value: unknown): EmbeddedRunAttemptParams { describe("runEmbeddedAgent Codex app-server recovery", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts index a764f7896904..5430292ce5b9 100644 --- a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts +++ b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts @@ -13,6 +13,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; @@ -20,6 +21,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; describe("runEmbeddedAgent Codex server_error fallback handoff", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts b/src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts index 04f948117d44..532de416be97 100644 --- a/src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts +++ b/src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts @@ -27,6 +27,7 @@ import { mockedIsLikelyContextOverflowError, mockedRunEmbeddedAttempt, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; @@ -99,6 +100,7 @@ async function executeWrappedToolOutcome( describe("post-compaction loop guard wired into runEmbeddedAgent", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); // Re-import after the harness reset so we share module instances with // the runner. The runner imports both modules through its own graph. ({ diagnosticSessionStates, getDiagnosticSessionState } = diff --git a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts index bed1e8a99273..f9db8dab5c40 100644 --- a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts +++ b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts @@ -6,6 +6,7 @@ import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { loadRunOverflowCompactionHarness, MockedFailoverError, + mockedClassifyFailoverReason, mockedFormatAssistantErrorText, mockedGlobalHookRunner, mockedIsFailoverAssistantError, @@ -13,11 +14,13 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; const DEEPSEEK_ERROR_MESSAGE = "429 deepseek rate limit"; +const COMPACTION_REMOVED_ERROR_MESSAGE = "current candidate model unavailable"; type CurrentAttemptAssistantWithError = NonNullable< EmbeddedRunAttemptResult["currentAttemptAssistant"] > & { errorMessage: string }; @@ -82,6 +85,39 @@ function makeCrossProviderFallbackConfig() { }); } +function setupCompactionRemovedFallbackAttempt() { + mockedIsFailoverAssistantError.mockImplementation((...args: unknown[]) => { + const assistant = args[0]; + return isCurrentAttemptAssistant(assistant) && assistant.provider === "anthropic"; + }); + mockedClassifyFailoverReason.mockReturnValue("model_not_found"); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: makeAssistantMessageFixture({ + stopReason: "error", + errorMessage: COMPACTION_REMOVED_ERROR_MESSAGE, + provider: "anthropic", + model: "test-model", + content: [], + }), + currentAttemptAssistant: undefined, + }), + ); +} + +function runCompactionRemovedFallbackAttempt() { + return runEmbeddedAgent({ + ...overflowBaseRunParams, + runId: "run-compaction-fallback-error-context", + config: makeCrossProviderFallbackConfig(), + agentHarnessRuntimeOverride: "openclaw", + provider: "anthropic", + model: "test-model", + modelFallbacksOverride: ["deepseek/deepseek-chat"], + }); +} + async function expectDeepseekFallbackError( promise: Promise, getLastFormattedAssistant: () => unknown, @@ -97,6 +133,14 @@ async function expectDeepseekFallbackError( describe("runEmbeddedAgent cross-provider fallback error handling", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent, { + config: makeCrossProviderFallbackConfig(), + agentHarnessRuntimeOverride: "openclaw", + provider: "deepseek", + model: "deepseek-chat", + }); + setupCompactionRemovedFallbackAttempt(); + await runCompactionRemovedFallbackAttempt().catch(() => undefined); }); beforeEach(() => { @@ -142,46 +186,18 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { it("falls back to the session assistant when compaction removes the current attempt slice", async () => { const getLastFormattedAssistant = captureFormattedAssistant(); - const sameCandidateErrorMessage = "429 current candidate rate limit"; - mockedIsFailoverAssistantError.mockImplementation((...args: unknown[]) => { - const assistant = args[0]; - return isCurrentAttemptAssistant(assistant) && assistant.provider === "anthropic"; - }); - mockedIsRateLimitAssistantError.mockImplementation((...args: unknown[]) => { - const assistant = args[0]; - return isCurrentAttemptAssistant(assistant) && assistant.provider === "anthropic"; - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeAssistantMessageFixture({ - stopReason: "error", - errorMessage: sameCandidateErrorMessage, - provider: "anthropic", - model: "test-model", - content: [], - }), - currentAttemptAssistant: undefined, - }), - ); - - const promise = runEmbeddedAgent({ - ...overflowBaseRunParams, - runId: "run-compaction-fallback-error-context", - config: makeCrossProviderFallbackConfig(), - agentHarnessRuntimeOverride: "openclaw", - provider: "anthropic", - model: "test-model", - modelFallbacksOverride: ["deepseek/deepseek-chat"], - }); + setupCompactionRemovedFallbackAttempt(); + const promise = runCompactionRemovedFallbackAttempt(); await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); - await expect(promise).rejects.toThrow(`anthropic/test-model: ${sameCandidateErrorMessage}`); - expect(mockedIsRateLimitAssistantError).toHaveBeenCalledTimes(1); + await expect(promise).rejects.toThrow( + `anthropic/test-model: ${COMPACTION_REMOVED_ERROR_MESSAGE}`, + ); + expect(mockedIsFailoverAssistantError).toHaveBeenCalledTimes(1); expect(getLastFormattedAssistant()).toMatchObject({ provider: "anthropic", model: "test-model", - errorMessage: sameCandidateErrorMessage, + errorMessage: COMPACTION_REMOVED_ERROR_MESSAGE, }); }); diff --git a/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts b/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts index 6dc4f0541e06..495608496588 100644 --- a/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts +++ b/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts @@ -9,6 +9,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -60,6 +61,7 @@ function successAttempt(provider: string, model: string): EmbeddedRunAttemptResu describe("runEmbeddedAgent silent-error retry", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.fast-mode-auto.test.ts b/src/agents/embedded-agent-runner/run.fast-mode-auto.test.ts index 80516d4c9cf4..db2965d0d5e1 100644 --- a/src/agents/embedded-agent-runner/run.fast-mode-auto.test.ts +++ b/src/agents/embedded-agent-runner/run.fast-mode-auto.test.ts @@ -12,6 +12,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -52,6 +53,7 @@ function resolveAttemptFastMode(params: unknown): void { describe("runEmbeddedAgent fast auto progress", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index 2ca19569c734..37d171c3243c 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -18,6 +18,7 @@ import { mockedSleepWithAbort, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import { buildAttemptReplayMetadata, @@ -53,15 +54,7 @@ function resolveIncompleteTurnPayloadText( describe("runEmbeddedAgent incomplete-turn safety", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); - resetRunOverflowCompactionHarnessMocks(); - mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ assistantTexts: ["warmup"] }), - ); - await runEmbeddedAgent({ - ...overflowBaseRunParams, - runId: "run-incomplete-turn-warmup", - }); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index 8680a88d6b70..493fb3cc1287 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -19,6 +19,7 @@ import type { import { resetCommandQueueStateForTest } from "../../process/command-queue.js"; import type { FailoverReason } from "../embedded-agent-helpers/types.js"; import { clearAgentHarnesses, registerAgentHarness } from "../harness/registry.js"; +import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import type { buildEmbeddedRunPayloads } from "./run/payloads.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -810,3 +811,19 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ const { runEmbeddedAgent } = await import("./run.js"); return { runEmbeddedAgent }; } + +/** Move one-time runner compilation out of individual behavior timings. */ +export async function warmRunOverflowCompactionHarness( + runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent, + params?: Partial[0]>, +): Promise { + resetRunOverflowCompactionHarnessMocks(); + mockedGlobalHookRunner.hasHooks.mockReturnValue(false); + mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "warmup" }]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ assistantTexts: ["warmup"] })); + await runEmbeddedAgent({ + ...overflowBaseRunParams, + ...params, + runId: params?.runId ?? "run-overflow-compaction-harness-warmup", + }); +} diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts index 8c5e6af1f113..7a33adb23adf 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts @@ -21,6 +21,7 @@ import { mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -66,6 +67,7 @@ function expectRetryContinuesFromTranscript() { describe("overflow compaction in run loop", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 366d6b06e6c4..54dc3195a82d 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -50,6 +50,7 @@ import { mockedWaitForDeferredTurnMaintenanceForSession, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { RunEmbeddedAgentParams } from "./run/params.js"; import type { EmbeddedRunAttemptParams } from "./run/types.js"; @@ -252,6 +253,7 @@ async function waitForRunEvent(events: string[], expected: string): Promise { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts index 812a83fdd86c..dead6e753001 100644 --- a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts +++ b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts @@ -9,6 +9,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; @@ -16,6 +17,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; describe("runEmbeddedAgent prompt timeout fallback handoff", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts index 0619d691347a..dc680c8a39bf 100644 --- a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts @@ -13,6 +13,7 @@ import { mockedRunPostCompactionSideEffects, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; @@ -114,6 +115,7 @@ function hookCallAt(index: number, kind: "before" | "after"): [HookEvent, HookCo describe("timeout-triggered compaction", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 68a515bc0533..946d496e90e3 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -4,7 +4,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveSessionTranscriptPathInDir } from "../../../config/sessions/paths.js"; import { appendSessionTranscriptEvent, @@ -43,6 +43,25 @@ const lockOptions = { }; const tempDirs: string[] = []; +const LARGE_LINEAR_TRANSCRIPT_LINE = `${JSON.stringify({ + type: "message", + id: "large-linear-user", + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "user", + content: [{ type: "text", text: "界".repeat(Math.ceil((7 * 1024 * 1024) / 3)) }], + }, +})}\n`; +const LARGE_LEGACY_DELIVERY_TRANSCRIPT_LINE = `${JSON.stringify({ + type: "message", + id: "large-legacy-user", + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "user", + content: [{ type: "text", text: "界".repeat(Math.ceil((8 * 1024 * 1024) / 3)) }], + }, +})}\n`; +const LARGE_OWNED_TRANSCRIPT_TEXT = "界".repeat(1024 * 1024); afterEach(async () => { vi.restoreAllMocks(); @@ -63,6 +82,19 @@ async function createTempSessionFile(): Promise { return sessionFile; } +async function readSessionFileTail(sessionFile: string, maxBytes = 512): Promise { + const stat = await fs.stat(sessionFile); + const length = Math.min(maxBytes, stat.size); + const buffer = Buffer.alloc(length); + const handle = await fs.open(sessionFile, "r"); + try { + const { bytesRead } = await handle.read(buffer, 0, length, stat.size - length); + return buffer.toString("utf8", 0, bytesRead); + } finally { + await handle.close(); + } +} + async function waitUntil(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 1_000; while (!predicate()) { @@ -1685,64 +1717,68 @@ describe("embedded attempt session lock lifecycle", () => { expect(controller.hasSessionTakeover()).toBe(false); }); - it("allows parentless delivery mirrors appended to large legacy linear transcripts", async () => { - const sessionFile = await createTempSessionFile(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "large-legacy-user", - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [{ type: "text", text: "x".repeat(8 * 1024 * 1024) }], + describe("large legacy linear transcript delivery", () => { + let result: { + hasTakeover: boolean; + lastLine?: string; + mergedEntries: unknown; + }; + + beforeAll(async () => { + const sessionFile = await createTempSessionFile(); + await fs.appendFile(sessionFile, LARGE_LEGACY_DELIVERY_TRANSCRIPT_LINE, "utf8"); + const mergePromptReleasedSessionEntries = vi.fn(); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock, + lockOptions: { ...lockOptions, sessionFile }, + mergePromptReleasedSessionEntries, + }); + + await controller.releaseForPrompt(); + const sessionKey = "agent:main:large-linear-delivery"; + await withOwnedSessionTranscriptWrites( + { + sessionFile, + sessionKey, + withSessionWriteLock: (operation, options) => + controller.withSessionWriteLock(operation, options), }, - })}\n`, - "utf8", - ); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, + async () => + await runWithOwnedSessionTranscriptWritePublication( + { sessionFile, sessionKey }, + async () => + await appendSessionTranscriptMessage({ + transcriptPath: sessionFile, + message: { + role: "assistant", + content: [{ type: "text", text: "mirrored large transcript delivery" }], + provider: "openclaw", + model: "delivery-mirror", + }, + }), + ), + ); + + result = { + hasTakeover: controller.hasSessionTakeover(), + lastLine: (await readSessionFileTail(sessionFile)).trimEnd().split("\n").at(-1), + mergedEntries: mergePromptReleasedSessionEntries.mock.calls[0]?.[0], + }; + await controller.dispose(); }); - await controller.releaseForPrompt(); - const sessionKey = "agent:main:large-linear-delivery"; - await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await runWithOwnedSessionTranscriptWritePublication( - { sessionFile, sessionKey }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "mirrored large transcript delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - }), - ), - ); - - const lastLine = (await fs.readFile(sessionFile, "utf8")).trimEnd().split("\n").at(-1); - expect(lastLine).toBeDefined(); - expect(JSON.parse(lastLine ?? "{}")).not.toHaveProperty("parentId"); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ - type: "message", - parentId: null, - message: expect.objectContaining({ model: "delivery-mirror" }), - }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); + it("allows parentless delivery mirrors appended to large legacy linear transcripts", () => { + expect(result.lastLine).toBeDefined(); + expect(JSON.parse(result.lastLine ?? "{}")).not.toHaveProperty("parentId"); + expect(result.mergedEntries).toEqual([ + expect.objectContaining({ + type: "message", + parentId: null, + message: expect.objectContaining({ model: "delivery-mirror" }), + }), + ]); + expect(result.hasTakeover).toBe(false); + }); }); it("refreshes the prompt fence after an owned write throws", async () => { @@ -2972,54 +3008,59 @@ describe("embedded attempt session lock lifecycle", () => { await controller.dispose(); }); - it("validates large owned entries after migrating a large linear transcript", async () => { - const sessionFile = await createTempSessionFile(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "large-linear-user", - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [{ type: "text", text: "x".repeat(7 * 1024 * 1024) }], + describe("large owned linear transcript migration", () => { + let result: { + appendedId: string; + hasTakeover: boolean; + mergedEntries: unknown; + persistedParentLink: boolean; + }; + + beforeAll(async () => { + const sessionFile = await createTempSessionFile(); + await fs.appendFile(sessionFile, LARGE_LINEAR_TRANSCRIPT_LINE, "utf8"); + const mergePromptReleasedSessionEntries = vi.fn(); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock, + lockOptions: { ...lockOptions, sessionFile }, + mergePromptReleasedSessionEntries, + }); + await controller.releaseForPrompt(); + + const appended = await withOwnedSessionTranscriptWrites( + { + sessionFile, + withSessionWriteLock: (operation, options) => + controller.withSessionWriteLock(operation, options), }, - })}\n`, - "utf8", - ); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, + async () => + await appendSessionTranscriptMessage({ + transcriptPath: sessionFile, + message: { + role: "assistant", + content: [{ type: "text", text: LARGE_OWNED_TRANSCRIPT_TEXT }], + provider: "anthropic", + model: "sonnet-4.6", + }, + }), + ); + + result = { + appendedId: appended.messageId, + hasTakeover: controller.hasSessionTakeover(), + mergedEntries: mergePromptReleasedSessionEntries.mock.calls[0]?.[0], + persistedParentLink: (await fs.readFile(sessionFile, "utf8")).includes('"parentId"'), + }; + await controller.dispose(); }); - await controller.releaseForPrompt(); - const appended = await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "y".repeat(3 * 1024 * 1024) }], - provider: "anthropic", - model: "sonnet-4.6", - }, - }), - ); - - const persisted = await fs.readFile(sessionFile, "utf8"); - expect(persisted).toContain('"parentId"'); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "message", id: appended.messageId }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); + it("validates large owned entries after migrating a large linear transcript", () => { + expect(result.persistedParentLink).toBe(true); + expect(result.mergedEntries).toEqual([ + expect.objectContaining({ type: "message", id: result.appendedId }), + ]); + expect(result.hasTakeover).toBe(false); + }); }); it("serializes concurrent nested owned transcript publications", async () => { diff --git a/src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts b/src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts index c51896ac2478..93f15cf57ec7 100644 --- a/src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts +++ b/src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts @@ -10,6 +10,7 @@ import { mockedGlobalHookRunner, mockedRunEmbeddedAttempt, overflowBaseRunParams, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import { isEmbeddedAgentRunActive, queueEmbeddedAgentMessageWithOutcome } from "./runs.js"; @@ -18,6 +19,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; describe("sessions_yield orchestration", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/embedded-agent-runner/usage-reporting.test.ts b/src/agents/embedded-agent-runner/usage-reporting.test.ts index 68339430ea3a..1383e3000009 100644 --- a/src/agents/embedded-agent-runner/usage-reporting.test.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test.ts @@ -8,6 +8,7 @@ import { mockedEnsureRuntimePluginsLoaded, mockedResolveModelAsync, mockedRunEmbeddedAttempt, + warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -44,6 +45,7 @@ function firstAttemptInput(): Record { describe("runEmbeddedAgent usage reporting", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + await warmRunOverflowCompactionHarness(runEmbeddedAgent); }); beforeEach(() => { diff --git a/src/agents/harness/runtime-plugin.test.ts b/src/agents/harness/runtime-plugin.test.ts index 29b831434677..a0b0272711d5 100644 --- a/src/agents/harness/runtime-plugin.test.ts +++ b/src/agents/harness/runtime-plugin.test.ts @@ -1,5 +1,5 @@ // Verifies plugin loading needed before agent harness selection. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; const mocks = vi.hoisted(() => ({ @@ -28,7 +28,12 @@ vi.mock("../../plugins/activation-planner.js", () => ({ describe("ensureSelectedAgentHarnessPlugin", () => { let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin; - beforeEach(async () => { + beforeAll(async () => { + vi.resetModules(); + ({ ensureSelectedAgentHarnessPlugin } = await import("./runtime-plugin.js")); + }); + + beforeEach(() => { mocks.ensurePluginRegistryLoaded.mockReset(); mocks.resolveActivatableProviderOwnerPluginIds.mockReset(); mocks.resolveBundledProviderCompatPluginIds.mockReset(); @@ -67,8 +72,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => { ({ pluginIds }: { pluginIds: readonly string[] }) => pluginIds.filter((pluginId) => pluginId === "memory-core"), ); - vi.resetModules(); - ({ ensureSelectedAgentHarnessPlugin } = await import("./runtime-plugin.js")); }); it("loads Codex and the provider owner when an explicit runtime override forces the Codex harness", async () => { diff --git a/src/agents/mcp-stdio-transport.ts b/src/agents/mcp-stdio-transport.ts index b9f9c53362ee..d0e13ec8a37c 100644 --- a/src/agents/mcp-stdio-transport.ts +++ b/src/agents/mcp-stdio-transport.ts @@ -118,6 +118,7 @@ export class OpenClawStdioClientTransport implements Transport { async close(): Promise { const processToClose = this.process ?? this.closingProcess; this.process = undefined; + this.closingProcess = processToClose; if (processToClose) { this.closingProcess = processToClose; } diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index d84ac2ecc8da..f65685d28f3b 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -2,8 +2,9 @@ * Regression coverage for model compatibility and live-model curation. * Exercises catalog compatibility, provider modernity hooks, and live sweep selection. */ +import path from "node:path"; import type { Api, Model } from "openclaw/plugin-sdk/llm"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const providerRuntimeMocks = vi.hoisted(() => ({ resolveProviderModernModelRef: vi.fn(), @@ -88,10 +89,17 @@ function expectNativeStreamingSupported(overrides: Partial): void { } beforeEach(() => { + // Endpoint capabilities come from manifests. Keep source tests independent + // from partial dist output left by an earlier build in the same checkout. + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions")); providerRuntimeMocks.resolveProviderModernModelRef.mockReset(); providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(undefined); }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("normalizeModelCompat — Anthropic baseUrl", () => { const anthropicBase = (): Model => ({ diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 473333c6f96e..1ae814352c20 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -1,9 +1,10 @@ // Verifies models.json planning applies config env vars and discovery scope. -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { __testing as externalAuthTesting } from "./auth-profiles/external-auth.js"; import { clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots, @@ -16,6 +17,11 @@ import { import type { ProviderConfig } from "./models-config.providers.secrets.js"; import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js"; +vi.mock("./provider-auth-aliases.js", () => ({ + resolveProviderAuthAliasMap: () => Object.create(null) as Record, + resolveProviderIdForAuth: (provider: string) => provider.trim().toLowerCase(), +})); + const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV"; function createImplicitOpenRouterProvider(): ProviderConfig { @@ -126,6 +132,59 @@ async function resolveProvidersAndCaptureDiscoveryEnv(cfg: OpenClawConfig) { let unauthenticatedProviderWritePlan: Awaited>; let unauthenticatedProviderParsed: { providers?: Record }; +async function planGoogleVertexProfileCatalog() { + const agentDir = "/tmp/openclaw-google-vertex-models-profile"; + try { + externalAuthTesting.setResolveExternalAuthProfilesForTest(() => []); + replaceRuntimeAuthProfileStoreSnapshots([ + { + store: { version: 1, profiles: {} }, + }, + { + agentDir, + store: { + version: 1, + profiles: { + "google-vertex:default": { + type: "api_key", + provider: "google-vertex", + keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" }, + }, + }, + }, + }, + ]); + + return await planOpenClawModelsJsonWithDeps( + { + cfg: { + agents: { + defaults: { + models: { + "google-vertex/gemini-2.5-pro": {}, + }, + model: { primary: "google-vertex/gemini-2.5-pro" }, + }, + }, + models: { providers: {} }, + }, + agentDir, + env: {}, + existingRaw: "", + existingParsed: null, + }, + { + resolveImplicitProviders: async () => ({ + "google-vertex": createImplicitGoogleVertexProvider(), + }), + }, + ); + } finally { + externalAuthTesting.resetResolveExternalAuthProfilesForTest(); + clearRuntimeAuthProfileStoreSnapshots(); + } +} + beforeAll(async () => { // Reused no-auth write plan proves generated providers stay serializable // even when discovery returns auth-only provider shells. @@ -154,6 +213,7 @@ beforeAll(async () => { unauthenticatedProviderParsed = JSON.parse(unauthenticatedProviderWritePlan.contents) as { providers?: Record; }; + await planGoogleVertexProfileCatalog(); }); describe("models-config", () => { @@ -466,67 +526,23 @@ describe("models-config", () => { }); it("keeps google-vertex static catalog rows when an auth profile supplies the API key", async () => { - const agentDir = "/tmp/openclaw-google-vertex-models-profile"; - try { - replaceRuntimeAuthProfileStoreSnapshots([ - { - agentDir, - store: { - version: 1, - profiles: { - "google-vertex:default": { - type: "api_key", - provider: "google-vertex", - keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" }, - }, - }, - }, - }, - ]); + const plan = await planGoogleVertexProfileCatalog(); - const plan = await planOpenClawModelsJsonWithDeps( - { - cfg: { - agents: { - defaults: { - models: { - "google-vertex/gemini-2.5-pro": {}, - }, - model: { primary: "google-vertex/gemini-2.5-pro" }, - }, - }, - models: { providers: {} }, - }, - agentDir, - env: {}, - existingRaw: "", - existingParsed: null, - }, - { - resolveImplicitProviders: async () => ({ - "google-vertex": createImplicitGoogleVertexProvider(), - }), - }, - ); - - expect(plan.action).toBe("write"); - if (plan.action !== "write") { - throw new Error("Expected models.json write plan"); - } - const parsed = JSON.parse(plan.contents) as { - providers?: Record< - string, - { apiKey?: string; api?: string; models?: Array<{ id?: string }> } - >; - }; - expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex"); - expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("GOOGLE_CLOUD_API_KEY"); - expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([ - "gemini-2.5-pro", - ]); - } finally { - clearRuntimeAuthProfileStoreSnapshots(); + expect(plan.action).toBe("write"); + if (plan.action !== "write") { + throw new Error("Expected models.json write plan"); } + const parsed = JSON.parse(plan.contents) as { + providers?: Record< + string, + { apiKey?: string; api?: string; models?: Array<{ id?: string }> } + >; + }; + expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex"); + expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("GOOGLE_CLOUD_API_KEY"); + expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([ + "gemini-2.5-pro", + ]); }); it("keeps google-vertex static catalog rows when discovery supplies the ADC marker", async () => { diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index 80ad748abc11..9d4aefde0f35 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -31,7 +31,7 @@ vi.mock("./model-auth-env-vars.js", () => { }; }); -vi.mock("../plugin-sdk/provider-http.js", () => ({ +vi.mock("./provider-attribution.js", () => ({ // Only the CN endpoint advertises native streaming usage in this contract. resolveProviderRequestCapabilities: (params: { provider: string; baseUrl?: string }) => ({ supportsNativeStreamingUsageCompat: diff --git a/src/agents/sessions/extensions/loader.test.ts b/src/agents/sessions/extensions/loader.test.ts index 561132bf2715..d438a2b8072d 100644 --- a/src/agents/sessions/extensions/loader.test.ts +++ b/src/agents/sessions/extensions/loader.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { loadExtensions } from "./loader.js"; const tempDirs: string[] = []; @@ -13,7 +13,9 @@ afterEach(async () => { }); describe("loadExtensions", () => { - it("resolves plugin SDK subpaths in jiti-loaded extensions", async () => { + let result: Awaited>; + + beforeAll(async () => { // Extensions import both public SDK helpers and runtime helper subpaths; the // loader must route those aliases without package-manager involvement. const dir = await mkdtemp(join(tmpdir(), "openclaw-extension-sdk-")); @@ -41,8 +43,10 @@ export default async function(api) { `, ); - const result = await loadExtensions([extensionPath], dir); + result = await loadExtensions([extensionPath], dir); + }); + it("resolves plugin SDK subpaths in jiti-loaded extensions", () => { expect(result.errors).toEqual([]); expect(result.extensions).toHaveLength(1); expect(result.extensions[0]?.commands.has("sdk-subpath-probe")).toBe(true); diff --git a/src/agents/utils/child-process.test.ts b/src/agents/utils/child-process.test.ts index 191ac22c3e4f..710acf4c3686 100644 --- a/src/agents/utils/child-process.test.ts +++ b/src/agents/utils/child-process.test.ts @@ -1,6 +1,7 @@ -import { spawn, type ChildProcessByStdio } from "node:child_process"; -import type { Readable } from "node:stream"; -import { afterEach, describe, expect, it } from "vitest"; +import { type ChildProcess, spawn, type ChildProcessByStdio } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough, type Readable } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { waitForChildProcess } from "./child-process.js"; describe.skipIf(process.platform === "win32")("waitForChildProcess", () => { @@ -49,22 +50,29 @@ describe.skipIf(process.platform === "win32")("waitForChildProcess", () => { }); it("bounds draining from a continuously writing descendant", async () => { - child = spawn( - "/bin/sh", - ["-c", 'printf "HEAD\\n"; ( while :; do printf "TICK\\n"; sleep 0.03; done ) &'], - { - stdio: ["ignore", "pipe", "pipe"], - detached: true, - }, - ); - let output = ""; - child.stdout.on("data", (chunk: Buffer) => { - output += chunk.toString(); - }); + vi.useFakeTimers(); + try { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const fakeChild = Object.assign(new EventEmitter(), { + stdout, + stderr, + }) as unknown as ChildProcess; + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); - const startedAt = Date.now(); - await expect(waitForChildProcess(child)).resolves.toBe(0); - expect(output).toContain("TICK"); - expect(Date.now() - startedAt).toBeLessThan(2_000); + const completion = waitForChildProcess(fakeChild); + fakeChild.emit("exit", 0); + const writer = setInterval(() => stdout.write("TICK\n"), 30); + + await vi.advanceTimersByTimeAsync(1_000); + await expect(completion).resolves.toBe(0); + clearInterval(writer); + expect(output).toContain("TICK"); + } finally { + vi.useRealTimers(); + } }); }); 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 af76cf473c82..53835c13319e 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { testing as cliBackendsTesting } from "../../agents/cli-backends.js"; import { testing as embeddedRunTesting, @@ -254,7 +254,7 @@ function firstMockCallArg(mock: MockCallSource, label: string): unknown { return call[0]; } -beforeEach(() => { +function setupAgentRunnerMocks(): void { vi.useRealTimers(); registerCliBackendsForTest(); clearRuntimeConfigSnapshot(); @@ -290,7 +290,9 @@ beforeEach(() => { model, }), ); -}); +} + +beforeEach(setupAgentRunnerMocks); afterEach(() => { cliBackendsTesting.resetDepsForTest(); @@ -432,6 +434,17 @@ describe("runReplyAgent auto-compaction token update", () => { return { sessionKey, stored, usageEvent }; } + beforeAll(async () => { + setupAgentRunnerMocks(); + await runBaseReplyWithAgentMeta({ + tmpPrefix: "openclaw-usage-warm-", + agentMeta: { + usage: { input: 10, output: 5, total: 15 }, + lastCallUsage: { input: 8, output: 2, total: 10 }, + }, + }); + }); + it("updates totalTokens from lastCallUsage even without compaction", async () => { const { sessionKey, stored } = await runBaseReplyWithAgentMeta({ tmpPrefix: "openclaw-usage-last-", diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 03197cc8ad62..4ad575afdd54 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -1,5 +1,5 @@ // Tests session lifecycle commands for fork, reset, restart, and cleanup. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -495,10 +495,8 @@ function expectIdleTimeoutSetReply( } describe("/session idle and /session max-age", () => { - beforeEach(async () => { - if (!handleSessionCommand) { - ({ handleSessionCommand } = await import("./commands-session.js")); - } + beforeAll(async () => { + ({ handleSessionCommand } = await import("./commands-session.js")); }); beforeEach(() => { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 8c6a1096f2c6..18a2209e0a9d 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -1483,6 +1483,22 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }; } + beforeAll(async () => { + const sessionEntry = createSessionEntry({ thinkingLevel: "xhigh" }); + await handleDirectiveOnly( + createHandleParams({ + directives: parseInlineDirectives("/model opencode/claude-opus-4-7"), + allowedModelKeys: new Set([...allowedModelKeys, "opencode/claude-opus-4-7"]), + allowedModelCatalog: [ + ...allowedModelCatalog, + { provider: "opencode", id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + ], + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + }), + ); + }); + it("shows success message when session state is available", async () => { const directives = parseInlineDirectives("/model openai/gpt-4o"); const sessionEntry = createSessionEntry(); diff --git a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts index 960c5c0e1bd3..7d6c67482e48 100644 --- a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts @@ -20,6 +20,28 @@ installReplyRuntimeMocks(agentMocks); describe("getReplyFromConfig fast-path runtime", () => { beforeAll(async () => { ({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url })); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + resetReplyRuntimeMocks(agentMocks); + agentMocks.runEmbeddedAgent.mockResolvedValue(makeEmbeddedTextResult("warm runtime")); + await withTempHome(async (home) => { + await getReplyFromConfig( + { + Body: "warm runtime", + BodyForAgent: "warm runtime", + RawBody: "warm runtime", + CommandBody: "warm runtime", + From: "+1001", + To: "+2000", + SessionKey: "agent:main:whatsapp:+2000", + Provider: "whatsapp", + Surface: "whatsapp", + ChatType: "direct", + }, + {}, + makeReplyConfig(home) as OpenClawConfig, + ); + }); + vi.unstubAllEnvs(); }); beforeEach(async () => { diff --git a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts index 77191ceabe59..ed310231cd53 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts @@ -144,10 +144,18 @@ export function installPluginContractRegistryShard(params: ContractShardParams) installEmptyShardSuite("plugin contract registry shard"); return; } + const pluginCache = new Map>>(); + beforeAll(async () => { + await Promise.all( + entries.map(async (entry) => { + pluginCache.set(entry.id, await getBundledChannelPluginAsync(entry.id)); + }), + ); + }); for (const entry of entries) { describe(`${entry.id} plugin contract`, () => { - it("satisfies the base channel plugin contract", async () => { - const plugin = await getBundledChannelPluginAsync(entry.id); + it("satisfies the base channel plugin contract", () => { + const plugin = pluginCache.get(entry.id); if (!plugin) { throw new Error(`Missing bundled channel plugin for ${entry.id}`); } diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 51cc745e0ccf..b01b8de99d0b 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -170,10 +170,90 @@ function mockNodeGatewayPlanFixture( }); } +async function buildPluginConfigExecSecretRefPlan(home: string) { + mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); + const pluginRoot = path.join(home, "acme-secrets"); + createSecurePluginRoot(pluginRoot); + writeSecurePluginEntrypoint(path.join(pluginRoot, "secret-ref-resolver.js")); + mocks.loadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "acme-secrets", + origin: "global", + rootDir: pluginRoot, + secretProviderIntegrations: { + "secret-store": { + source: "exec", + command: "${node}", + args: ["./secret-ref-resolver.js"], + passEnv: ["ACME_SECRETS_TOKEN"], + }, + }, + }, + ], + }); + mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "acme-plugin", + origin: "global", + configContracts: { + secretInputs: { + paths: [{ path: "apiKey", expected: "string" }], + }, + }, + }, + ], + }); + + return await buildGatewayInstallPlan({ + env: { HOME: home, ACME_SECRETS_TOKEN: "secret-token" }, + port: 3000, + runtime: "node", + config: { + plugins: { + enabled: true, + entries: { + "acme-plugin": { + enabled: true, + config: { + apiKey: { + source: "exec", + provider: "team-secrets", + id: "providers/acme-plugin/apiKey", + }, + }, + }, + }, + }, + secrets: { + providers: { + "team-secrets": { + source: "exec", + pluginIntegration: { + pluginId: "acme-secrets", + integrationId: "secret-store", + }, + }, + }, + }, + }, + }); +} + describe("buildGatewayInstallPlan", () => { beforeAll(async () => { const { resolveConfigSecretTargetByPath } = await import("../secrets/target-registry.js"); resolveConfigSecretTargetByPath(["channels", "discord", "token"]); + const warmHome = fs.mkdtempSync(path.join(os.tmpdir(), "oc-plan-plugin-warm-")); + try { + await buildPluginConfigExecSecretRefPlan(warmHome); + } finally { + fs.rmSync(warmHome, { recursive: true, force: true }); + } + vi.clearAllMocks(); }); // Prevent tests from reading the developer's real ~/.openclaw/.env when @@ -614,82 +694,7 @@ describe("buildGatewayInstallPlan", () => { }); it("includes passEnv values for plugin config exec SecretRefs", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - OPENCLAW_PORT: "3000", - }, - }); - const pluginRoot = path.join(isolatedHome, "acme-secrets"); - createSecurePluginRoot(pluginRoot); - writeSecurePluginEntrypoint(path.join(pluginRoot, "secret-ref-resolver.js")); - mocks.loadPluginManifestRegistry.mockReturnValue({ - diagnostics: [], - plugins: [ - { - id: "acme-secrets", - origin: "global", - rootDir: pluginRoot, - secretProviderIntegrations: { - "secret-store": { - source: "exec", - command: "${node}", - args: ["./secret-ref-resolver.js"], - passEnv: ["ACME_SECRETS_TOKEN"], - }, - }, - }, - ], - }); - mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ - diagnostics: [], - plugins: [ - { - id: "acme-plugin", - origin: "global", - configContracts: { - secretInputs: { - paths: [{ path: "apiKey", expected: "string" }], - }, - }, - }, - ], - }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv({ - ACME_SECRETS_TOKEN: "secret-token", - }), - port: 3000, - runtime: "node", - config: { - plugins: { - enabled: true, - entries: { - "acme-plugin": { - enabled: true, - config: { - apiKey: { - source: "exec", - provider: "team-secrets", - id: "providers/acme-plugin/apiKey", - }, - }, - }, - }, - }, - secrets: { - providers: { - "team-secrets": { - source: "exec", - pluginIntegration: { - pluginId: "acme-secrets", - integrationId: "secret-store", - }, - }, - }, - }, - }, - }); + const plan = await buildPluginConfigExecSecretRefPlan(isolatedHome); expect(plan.environment.ACME_SECRETS_TOKEN).toBe("secret-token"); expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined(); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 9b90da82a8d2..ac06d7e859d2 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1511,6 +1511,14 @@ describe("doctor config flow", () => { }, }, }); + await collectDoctorWarnings({ + channels: { + googlechat: { + groupPolicy: "allowlist", + accounts: { work: { groupPolicy: "allowlist" } }, + }, + }, + }); }); beforeEach(() => { diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 5b141707d66a..e14aa7fddfbd 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -1,7 +1,7 @@ // Doctor security tests cover security audit checks, config findings, and repair output. import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; @@ -29,6 +29,11 @@ describe("noteSecurityWarnings gateway exposure", () => { let prevHome: string | undefined; let prevServiceKind: string | undefined; + beforeAll(async () => { + listReadOnlyChannelPluginsForConfigMock.mockReturnValue([]); + await noteSecurityWarnings({ gateway: { bind: "loopback" } } as OpenClawConfig); + }); + beforeEach(() => { note.mockClear(); listReadOnlyChannelPluginsForConfigMock.mockReset(); diff --git a/src/config/sessions/session-accessor.reply-init-concurrency.test.ts b/src/config/sessions/session-accessor.reply-init-concurrency.test.ts index 25de1d5339b5..06c90411d4f2 100644 --- a/src/config/sessions/session-accessor.reply-init-concurrency.test.ts +++ b/src/config/sessions/session-accessor.reply-init-concurrency.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { loadSessionEntry, updateSessionEntry, upsertSessionEntry } from "./session-accessor.js"; vi.mock("../config.js", async () => ({ @@ -152,6 +152,24 @@ async function waitForChild(child: ReturnType): Promise { } describe("reply session initialization concurrency", () => { + beforeAll(async () => { + const sessionAccessorUrl = pathToFileURL( + path.resolve("src/config/sessions/session-accessor.ts"), + ).href; + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + `await import(${JSON.stringify(sessionAccessorUrl)})`, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + await waitForChild(child); + }); + it("commits after same-session activity from another process", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-reply-init-")); const sessionAccessorUrl = pathToFileURL( diff --git a/src/cron/isolated-agent.delivery-awareness.test.ts b/src/cron/isolated-agent.delivery-awareness.test.ts index 1c4822fc05a9..9d89b4a4a458 100644 --- a/src/cron/isolated-agent.delivery-awareness.test.ts +++ b/src/cron/isolated-agent.delivery-awareness.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import "./isolated-agent.mocks.js"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { CliDeps } from "../cli/deps.js"; import { resolveDefaultSessionStorePath } from "../config/sessions.js"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; @@ -49,6 +49,22 @@ async function runAnnounceTurn(params: { } describe("runCronIsolatedAgentTurn cron delivery awareness", () => { + beforeAll(async () => { + setupIsolatedAgentTurnMocks(); + resetCompletedDirectCronDeliveriesForTests(); + resetSystemEventsForTest(); + await withTempCronHome(async (home) => { + const storePath = await writeDefaultAgentSessionStoreEntries({}); + mockAgentPayloads([{ text: "warm runtime" }]); + await runAnnounceTurn({ + home, + storePath, + sessionKey: "cron:warm-runtime", + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }); + }); + }); + beforeEach(() => { setupIsolatedAgentTurnMocks(); resetCompletedDirectCronDeliveriesForTests(); diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts index f9118d03539f..1f5869355841 100644 --- a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -1,6 +1,6 @@ // Direct delivery tests cover isolated agent delivery through core channel targets. import "./isolated-agent.mocks.js"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { ChannelOutboundAdapter, ChannelOutboundContext } from "../channels/plugins/types.js"; import type { CliDeps } from "../cli/deps.js"; @@ -251,54 +251,70 @@ async function expectTelegramAnnounceDelivery({ }); } +function setupCoreChannelMocks(): void { + setupIsolatedAgentTurnMocks({ fast: true }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "slack", + plugin: createOutboundTestPlugin({ + id: "slack", + outbound: createCliDelegatingOutbound({ channel: "slack" }), + }), + source: "test", + }, + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ + id: "discord", + outbound: createCliDelegatingOutbound({ + channel: "discord", + preferFinalAssistantVisibleText: true, + }), + }), + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createOutboundTestPlugin({ + id: "whatsapp", + outbound: createCliDelegatingOutbound({ + channel: "whatsapp", + deliveryMode: "gateway", + resolveTarget: identityResolveTarget, + }), + }), + source: "test", + }, + { + pluginId: "imessage", + plugin: createOutboundTestPlugin({ + id: "imessage", + outbound: createCliDelegatingOutbound({ channel: "imessage" }), + }), + source: "test", + }, + ]), + ); +} + describe("runCronIsolatedAgentTurn core-channel direct delivery", () => { - beforeEach(() => { - setupIsolatedAgentTurnMocks({ fast: true }); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "slack", - plugin: createOutboundTestPlugin({ - id: "slack", - outbound: createCliDelegatingOutbound({ channel: "slack" }), - }), - source: "test", - }, - { - pluginId: "discord", - plugin: createOutboundTestPlugin({ - id: "discord", - outbound: createCliDelegatingOutbound({ - channel: "discord", - preferFinalAssistantVisibleText: true, - }), - }), - source: "test", - }, - { - pluginId: "whatsapp", - plugin: createOutboundTestPlugin({ - id: "whatsapp", - outbound: createCliDelegatingOutbound({ - channel: "whatsapp", - deliveryMode: "gateway", - resolveTarget: identityResolveTarget, - }), - }), - source: "test", - }, - { - pluginId: "imessage", - plugin: createOutboundTestPlugin({ - id: "imessage", - outbound: createCliDelegatingOutbound({ channel: "imessage" }), - }), - source: "test", - }, - ]), - ); + beforeAll(async () => { + setupCoreChannelMocks(); + const slack = CASES[0]; + if (!slack) { + throw new Error("expected Slack channel case"); + } + await expectCoreChannelAnnounceDelivery({ + testCase: slack, + payloads: [{ text: "warm runtime" }], + assertSend: () => {}, + }); + clearRuntimeConfigSnapshot(); }); + beforeEach(setupCoreChannelMocks); + afterEach(() => { clearRuntimeConfigSnapshot(); }); diff --git a/src/cron/isolated-agent.hook-content-wrapping.test.ts b/src/cron/isolated-agent.hook-content-wrapping.test.ts index 624aee5bd3bb..9f177e8f6467 100644 --- a/src/cron/isolated-agent.hook-content-wrapping.test.ts +++ b/src/cron/isolated-agent.hook-content-wrapping.test.ts @@ -1,6 +1,6 @@ // Hook content wrapping tests cover isolated agent message wrapping for hooks. import "./isolated-agent.mocks.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { makeCfg } from "./isolated-agent.test-harness.js"; @@ -24,6 +24,19 @@ function lastEmbeddedPrompt(): string { } describe("runCronIsolatedAgentTurn hook content wrapping", () => { + beforeAll(async () => { + process.env.OPENCLAW_TEST_FAST = "1"; + vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off"); + vi.mocked(loadModelCatalog).mockResolvedValue([]); + await withTempHome(async (home) => { + await runCronTurn(home, { + jobPayload: { kind: "agentTurn", message: "warm runtime" }, + message: "warm runtime", + sessionKey: "hook:gmail:warm-runtime", + }); + }); + }); + beforeEach(() => { process.env.OPENCLAW_TEST_FAST = "1"; vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off"); diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index d42734978f32..dcee57927ed7 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -2,7 +2,7 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as modelThinkingDefault from "../agents/model-thinking-default.js"; import type { SessionEntry } from "../config/sessions.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; @@ -24,7 +24,10 @@ import { import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-helpers.js"; import { dispatchCronDeliveryMock, + makeCronSession, mockRunCronFallbackPassthrough, + resetRunCronIsolatedAgentTurnHarness, + resolveCronSessionMock, runEmbeddedAgentMock, updateSessionStoreMock, } from "./isolated-agent/run.test-harness.js"; @@ -61,6 +64,16 @@ function lastEmbeddedAgentCall(): { } describe("runCronIsolatedAgentTurn session identity", () => { + beforeAll(async () => { + resetRunCronIsolatedAgentTurnHarness(); + resolveCronSessionMock.mockReturnValue(makeCronSession()); + vi.spyOn(modelThinkingDefault, "resolveThinkingDefault").mockReturnValue("off"); + mockRunCronFallbackPassthrough(); + await withTempHome(async (home) => { + await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD }); + }); + }); + beforeEach(() => { vi.spyOn(modelThinkingDefault, "resolveThinkingDefault").mockReturnValue("off"); runEmbeddedAgentMock.mockClear(); diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index 2b71b87aeee4..84b04a51312b 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -1,7 +1,7 @@ // Chat transcript parent-id tests protect gateway-injected assistant appends so // compaction history remains connected and transcript listeners receive updates. import fs from "node:fs"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { appendInjectedAssistantMessageToTranscript } from "./chat-transcript-inject.js"; import { createTranscriptFixtureSync } from "./chat.test-helpers.js"; @@ -37,9 +37,54 @@ function readLastTranscriptRecord(transcriptPath: string): Record; } +function readLastTranscriptRecordFromTail(transcriptPath: string): Record { + const size = fs.statSync(transcriptPath).size; + const length = Math.min(size, 64 * 1024); + const buffer = Buffer.allocUnsafe(length); + const fd = fs.openSync(transcriptPath, "r"); + let bytesRead = 0; + try { + bytesRead = fs.readSync(fd, buffer, 0, length, size - length); + } finally { + fs.closeSync(fd); + } + const lines = buffer.subarray(0, bytesRead).toString("utf8").trimEnd().split(/\r?\n/); + return JSON.parse(lines.at(-1) as string) as Record; +} + // Guardrail: Gateway-injected assistant transcript messages must attach to the // current leaf with a `parentId` and must not sever compaction history. describe("gateway chat.inject transcript writes", () => { + let oversizedDir = ""; + let oversizedTranscriptPath = ""; + + beforeAll(() => { + const fixture = createTranscriptFixtureSync({ + prefix: "openclaw-chat-inject-large-", + sessionId: "sess-1", + }); + oversizedDir = fixture.dir; + oversizedTranscriptPath = fixture.transcriptPath; + fs.appendFileSync( + oversizedTranscriptPath, + `${JSON.stringify({ + type: "message", + id: "legacy-large-message", + message: { + role: "assistant", + content: [{ type: "text", text: "x".repeat(9 * 1024 * 1024) }], + }, + })}\n`, + "utf-8", + ); + }); + + afterAll(() => { + if (oversizedDir) { + fs.rmSync(oversizedDir, { recursive: true, force: true }); + } + }); + it("appends a agent session entry that includes parentId", async () => { const { dir, transcriptPath } = createTranscriptFixtureSync({ prefix: "openclaw-chat-inject-", @@ -61,35 +106,15 @@ describe("gateway chat.inject transcript writes", () => { }); it("uses raw append for oversized append-only transcripts", async () => { - const { dir, transcriptPath } = createTranscriptFixtureSync({ - prefix: "openclaw-chat-inject-large-", - sessionId: "sess-1", - }); + const sizeBefore = fs.statSync(oversizedTranscriptPath).size; + const messageId = await appendHelloAndRequireId(oversizedTranscriptPath); + const last = readLastTranscriptRecordFromTail(oversizedTranscriptPath); - try { - fs.appendFileSync( - transcriptPath, - `${JSON.stringify({ - type: "message", - id: "legacy-large-message", - message: { - role: "assistant", - content: [{ type: "text", text: "x".repeat(9 * 1024 * 1024) }], - }, - })}\n`, - "utf-8", - ); - - const messageId = await appendHelloAndRequireId(transcriptPath); - const last = readLastTranscriptRecord(transcriptPath); - - expect(last.type).toBe("message"); - expect(last).toHaveProperty("id", messageId); - expect(last).toHaveProperty("message"); - expect(Object.hasOwn(last, "parentId")).toBe(false); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(fs.statSync(oversizedTranscriptPath).size).toBeGreaterThan(sizeBefore); + expect(last.type).toBe("message"); + expect(last).toHaveProperty("id", messageId); + expect(last).toHaveProperty("message"); + expect(Object.hasOwn(last, "parentId")).toBe(false); }); it("emits and returns the redacted injected assistant message", async () => { diff --git a/src/gateway/server-plugins.lifecycle.test.ts b/src/gateway/server-plugins.lifecycle.test.ts index 3a9e53299e3e..da669f7d3b85 100644 --- a/src/gateway/server-plugins.lifecycle.test.ts +++ b/src/gateway/server-plugins.lifecycle.test.ts @@ -1,20 +1,34 @@ /** * Tests gateway plugin lifecycle loading, startup, and shutdown behavior. */ -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { clearFallbackGatewayContext, createGatewaySubagentRuntime } from "./server-plugins.js"; import { installGatewayTestHooks, startServer } from "./test-helpers.server.js"; -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); afterEach(() => { clearFallbackGatewayContext(); }); describe("gateway plugin fallback context lifecycle", () => { + let started: Awaited> | undefined; + + beforeAll(async () => { + const warm = await startServer(); + await warm.server.close({ reason: "warm fallback context lifecycle" }); + started = await startServer(); + }); + + afterAll(async () => { + await started?.server.close({ reason: "fallback context lifecycle cleanup" }); + }); + it("clears the fallback gateway context after server close", async () => { const runtime = createGatewaySubagentRuntime(); - const started = await startServer(); + if (!started) { + throw new Error("expected gateway server to start"); + } try { await expect( @@ -22,6 +36,7 @@ describe("gateway plugin fallback context lifecycle", () => { ).resolves.toEqual({ messages: [] }); } finally { await started.server.close({ reason: "fallback context lifecycle test done" }); + started = undefined; } await expect( diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 648b46d454fc..10009ec81279 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -245,6 +245,8 @@ vi.mock("./server-tailscale.js", () => ({ const { startGatewayPostAttachRuntime, startGatewaySidecars, testing } = await import("./server-startup-post-attach.js"); const { STARTUP_UNAVAILABLE_GATEWAY_METHODS } = await import("./methods/core-descriptors.js"); +const { createGatewayCloseHandler } = await import("./server-close.js"); +const { createChatRunState } = await import("./server-chat-state.js"); type PostAttachParams = Parameters[0]; type PostAttachRuntimeDeps = NonNullable[1]>; @@ -1894,9 +1896,6 @@ describe("startGatewayPostAttachRuntime", () => { const postReadySidecars = [{ stop: vi.fn() }]; const stopChannel = vi.fn(async () => {}); const pluginServices = { stop: vi.fn(async () => {}) }; - const { createGatewayCloseHandler } = await import("./server-close.js"); - const { createChatRunState } = await import("./server-chat-state.js"); - const close = createGatewayCloseHandler({ bonjourStop: null, tailscaleCleanup: null, diff --git a/src/gateway/server-startup-web-fetch-bind.test.ts b/src/gateway/server-startup-web-fetch-bind.test.ts index 1a8346486af6..a33f9a809a35 100644 --- a/src/gateway/server-startup-web-fetch-bind.test.ts +++ b/src/gateway/server-startup-web-fetch-bind.test.ts @@ -2,7 +2,7 @@ * Gateway startup web fetch bind tests. */ import http from "node:http"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js"; import { readClientResponseBody } from "./test-http-response.js"; @@ -40,7 +40,7 @@ vi.mock("../secrets/runtime-web-tools-public-artifacts.runtime.js", async () => }; }); -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); afterEach(() => { webFetchProviderDiscovery.resolveBundledWebFetchProvidersFromPublicArtifactsMock.mockClear(); @@ -71,53 +71,58 @@ async function writeConfig(config: OpenClawConfig): Promise { } describe("gateway startup web fetch config", () => { - it("binds HTTP with credential-free tools.web.fetch config without fetch provider discovery", async () => { - const previousMinimal = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; + let port: number; + let previousMinimal: string | undefined; + let server: Awaited> | undefined; + + beforeAll(async () => { + previousMinimal = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "0"; - let server: Awaited> | undefined; - try { - await writeConfig({ - gateway: { - mode: "local", - bind: "loopback", - auth: { mode: "none" }, - }, - plugins: { - enabled: true, - allow: [], - entries: {}, - }, - tools: { - web: { - fetch: { - enabled: true, - maxChars: 200_000, - maxCharsCap: 2_000_000, - }, + await writeConfig({ + gateway: { + mode: "local", + bind: "loopback", + auth: { mode: "none" }, + }, + plugins: { + enabled: true, + allow: [], + entries: {}, + }, + tools: { + web: { + fetch: { + enabled: true, + maxChars: 200_000, + maxCharsCap: 2_000_000, }, }, - } as OpenClawConfig); + }, + } as OpenClawConfig); - const port = await getFreePort(); - server = await startGatewayServer(port, { - auth: { mode: "none" }, - }); + port = await getFreePort(); + server = await startGatewayServer(port, { + auth: { mode: "none" }, + }); + }); - const response = await requestHealthz(port); - expect(response.status).toBe(200); - expect( - webFetchProviderDiscovery.resolveBundledWebFetchProvidersFromPublicArtifactsMock, - ).not.toHaveBeenCalled(); - expect(webFetchProviderDiscovery.resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled(); - } finally { - if (server) { - await server.close(); - } - if (previousMinimal === undefined) { - delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; - } else { - process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimal; - } + afterAll(async () => { + if (server) { + await server.close(); + } + if (previousMinimal === undefined) { + delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; + } else { + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimal; } }); + + it("binds HTTP with credential-free tools.web.fetch config without fetch provider discovery", async () => { + const response = await requestHealthz(port); + expect(response.status).toBe(200); + expect( + webFetchProviderDiscovery.resolveBundledWebFetchProvidersFromPublicArtifactsMock, + ).not.toHaveBeenCalled(); + expect(webFetchProviderDiscovery.resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index cea4b1703e81..7522609796a8 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -11,7 +11,11 @@ import { import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; -import { buildGatewaySessionInfo, listSessionsFromStore } from "./session-utils.js"; +import { + buildGatewaySessionInfo, + filterAndSortSessionEntries, + listSessionsFromStore, +} from "./session-utils.js"; const MAIN_SESSION_KEY = "agent:main:main"; const MAIN_SESSION_ID = "sess-main"; @@ -81,6 +85,17 @@ function createLegacyRuntimeStore(model: string): Record { }; } +function buildLegacyRuntimeRow(cfg: OpenClawConfig, model: string) { + const store = createLegacyRuntimeStore(model); + return buildGatewaySessionInfo({ + cfg, + storePath: "/tmp/sessions.json", + store, + key: MAIN_SESSION_KEY, + entry: store[MAIN_SESSION_KEY], + }); +} + function createOpenAiPricingConfig(params: { id: string; label: string; @@ -325,6 +340,20 @@ function childTranscriptEntry(sessionId: string, now: number): SessionEntry { } describe("listSessionsFromStore search", () => { + beforeAll(() => { + listSessionsFromStore({ + cfg: createModelDefaultsConfig({ primary: "anthropic/claude-sonnet-4-6" }), + store: { + "agent:main:warm-runtime": { + sessionId: "sess-warm-runtime", + updatedAt: Date.now(), + } as SessionEntry, + }, + storePath: "/tmp/openclaw-session-search-warm.json", + opts: { search: "anthropic" }, + }); + }); + beforeAll(() => { listSessionsFromStore({ cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), @@ -460,14 +489,14 @@ describe("listSessionsFromStore search", () => { ] as const; for (const testCase of cases) { - const result = listSearchSessions({ + const entries = filterAndSortSessionEntries({ cfg, store, opts: { search: testCase.search }, + now, }); - expect(result.sessions.map((session) => session.key)).toEqual([testCase.expectedKey]); - expect(result.totalCount).toBe(1); + expect(entries.map(([key]) => key)).toEqual([testCase.expectedKey]); } }); @@ -539,14 +568,10 @@ describe("listSessionsFromStore search", () => { expectedProvider: "vercel-ai-gateway", }, ])("$name", ({ cfg, runtimeModel, expectedProvider }) => { - const result = listSearchSessions({ - cfg, - store: createLegacyRuntimeStore(runtimeModel), - opts: {}, - }); + const row = buildLegacyRuntimeRow(cfg, runtimeModel); - expect(result.sessions[0]?.modelProvider).toBe(expectedProvider); - expect(result.sessions[0]?.model).toBe(runtimeModel); + expect(row.modelProvider).toBe(expectedProvider); + expect(row.model).toBe(runtimeModel); }); test("exposes unknown totals when freshness is stale or missing", () => { diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 9a6c0ccae1d6..17d14085eee5 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -240,10 +240,10 @@ describe("OpenAI-compatible image provider helper", () => { }); it("accepts valid multi-image JSON above the generic provider JSON cap", async () => { - const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1); + const imageBytes = Buffer.alloc(3 * 1024 * 1024 + 64 * 1024, 1); postJsonRequestMock.mockResolvedValue({ response: jsonResponse({ - data: Array.from({ length: 3 }, () => ({ + data: Array.from({ length: 4 }, () => ({ b64_json: imageBytes.toString("base64"), })), }), @@ -255,15 +255,16 @@ describe("OpenAI-compatible image provider helper", () => { provider: "sample", model: "sample-image", prompt: "large", - count: 3, + count: 4, cfg: {} as never, }); - expect(result.images).toHaveLength(3); + expect(result.images).toHaveLength(4); expect(result.images.map((image) => image.buffer.byteLength)).toEqual([ imageBytes.byteLength, imageBytes.byteLength, imageBytes.byteLength, + imageBytes.byteLength, ]); }); diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index efdfe463f133..a598722923e2 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -1,5 +1,6 @@ // Image runtime tests cover model-backed image routing, auth/profile handling, // provider payload transforms, and MiniMax/Copilot special paths. +import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -138,11 +139,15 @@ const { describeImageWithModel } = await import("./image.js"); describe("describeImageWithModel", () => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); beforeEach(() => { + // Provider endpoint policy comes from manifests. Pin source manifests so a + // prior local build cannot make this source-checkout test read partial dist output. + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions")); vi.stubGlobal("fetch", fetchMock); vi.clearAllMocks(); fetchMock.mockResolvedValue({ diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index a144a532a211..22c56b598192 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { resolveAuthProfileDatabasePath, writePersistedAuthProfileStoreRaw, @@ -222,6 +222,17 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { describe("secrets audit", () => { let fixture: AuditFixture; + beforeAll(async () => { + const warmFixture = await createAuditFixture(); + try { + await seedAuditFixture(warmFixture); + await runSecretsAudit({ env: warmFixture.env }); + } finally { + closeOpenClawAgentDatabasesForTest(); + await fs.rm(warmFixture.rootDir, { recursive: true, force: true }); + } + }); + async function writeModelsProvider( overrides: Partial<{ apiKey: unknown; diff --git a/src/secrets/configure-plan.test.ts b/src/secrets/configure-plan.test.ts index 8547dcda2df7..2d9abbb40c38 100644 --- a/src/secrets/configure-plan.test.ts +++ b/src/secrets/configure-plan.test.ts @@ -17,6 +17,7 @@ import { resolveConfigSecretTargetByPath } from "./target-registry.js"; describe("secrets configure plan helpers", () => { beforeAll(() => { resolveConfigSecretTargetByPath(["channels", "telegram", "botToken"]); + buildConfigureCandidates({} as OpenClawConfig); }); it("builds configure candidates from supported configure targets", () => { diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 45369a479e2f..f8b03e354cbe 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -267,22 +267,35 @@ describe("secret ref resolver", () => { const scriptPath = await writeForkingNoOutputScript(root); const pidPath = path.join(root, "forked.pid"); let childPid: number | undefined; + const nativeSetTimeout = globalThis.setTimeout; + const noOutputTimeouts: Array<() => void> = []; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback, delay, ...args) => { + if (delay === 1_000) { + noOutputTimeouts.push(() => callback(...args)); + return nativeSetTimeout(() => undefined, 60_000); + } + return nativeSetTimeout(callback, delay, ...args); + }); try { - await expect( - resolveExecSecret(scriptPath, { - env: { NODE_BINARY: process.execPath, PID_FILE: pidPath }, - // The first no-output window must absorb shell spawn latency under - // parallel-suite load; the script's readiness byte then pins the - // killing silence window after the pid write. - noOutputTimeoutMs: 1000, - timeoutMs: 10_000, - }), - ).rejects.toThrow('Exec provider "execmain" produced no output'); - + const resultPromise = resolveExecSecret(scriptPath, { + env: { NODE_BINARY: process.execPath, PID_FILE: pidPath }, + // Preserve production-like startup headroom; the test fires the + // re-armed timer only after the readiness byte arrives. + noOutputTimeoutMs: 1_000, + timeoutMs: 10_000, + }); + await vi.waitFor(() => { + expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2); + }); childPid = await readPidFile(pidPath); + noOutputTimeouts.at(-1)?.(); + await expect(resultPromise).rejects.toThrow('Exec provider "execmain" produced no output'); expect(await waitForPidToExit(childPid, 5_000)).toBe(true); } finally { + setTimeoutSpy.mockRestore(); killPidIfAlive(childPid); } }); diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 4947cd3d282d..b46031a5a98b 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -633,6 +633,9 @@ function applyConfigForOpenClawTarget( if (entry.id === "plugins.entries.minimax.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "minimax"); } + if (entry.id === "plugins.entries.parallel.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "parallel"); + } if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily"); } diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index a814af1ca939..7249488fd769 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -30,7 +30,12 @@ let compiledCoreOpenClawTargetState: { knownTargetIds: Set; openClawCompiledSecretTargets: CompiledTargetRegistryEntry[]; openClawTargetsById: Map; - targetsByType: Map; + planTargetsByType: Map; +} | null = null; + +let compiledCoreAuthProfileTargetState: { + entries: CompiledTargetRegistryEntry[]; + entriesById: Map; } | null = null; // Channel contract entries are process-stable; plugin install/reload is the owner of freshness. @@ -99,18 +104,33 @@ function getCompiledCoreOpenClawTargetState() { if (compiledCoreOpenClawTargetState) { return compiledCoreOpenClawTargetState; } - const openClawCompiledSecretTargets = getCoreSecretTargetRegistry() - .filter((entry) => entry.configFile === "openclaw.json") - .map(compileTargetRegistryEntry); + const compiledCoreSecretTargets = getCoreSecretTargetRegistry().map(compileTargetRegistryEntry); + const openClawCompiledSecretTargets = compiledCoreSecretTargets.filter( + (entry) => entry.configFile === "openclaw.json", + ); compiledCoreOpenClawTargetState = { - knownTargetIds: new Set(openClawCompiledSecretTargets.map((entry) => entry.id)), + knownTargetIds: new Set(compiledCoreSecretTargets.map((entry) => entry.id)), openClawCompiledSecretTargets, openClawTargetsById: buildConfigTargetIdIndex(openClawCompiledSecretTargets), - targetsByType: buildTargetTypeIndex(openClawCompiledSecretTargets), + planTargetsByType: buildTargetTypeIndex(compiledCoreSecretTargets), }; return compiledCoreOpenClawTargetState; } +function getCompiledCoreAuthProfileTargetState() { + if (compiledCoreAuthProfileTargetState) { + return compiledCoreAuthProfileTargetState; + } + const entries = getCoreSecretTargetRegistry() + .filter((entry) => entry.configFile === "auth-profiles.json") + .map(compileTargetRegistryEntry); + compiledCoreAuthProfileTargetState = { + entries, + entriesById: buildConfigTargetIdIndex(entries), + }; + return compiledCoreAuthProfileTargetState; +} + function getCompiledChannelOpenClawTargets( channelId: string, ): CompiledTargetRegistryEntry[] | null { @@ -300,13 +320,16 @@ export function resolvePlanTargetAgainstRegistry(candidate: { providerId?: string; accountId?: string; }): ResolvedPlanTarget | null { - const coreEntries = getCompiledCoreOpenClawTargetState().targetsByType.get(candidate.type); + const coreEntries = getCompiledCoreOpenClawTargetState().planTargetsByType.get(candidate.type); if (coreEntries) { return resolvePlanTargetAgainstEntries(candidate, coreEntries); } const explicitChannelId = candidate.pathSegments[0] === "channels" ? (candidate.pathSegments[1]?.trim() ?? "") : ""; if (explicitChannelId) { + if (/[\\/:]/.test(explicitChannelId)) { + return null; + } const channelEntries = getCompiledChannelOpenClawTargets(explicitChannelId) ?? []; const channelTypeEntries = buildTargetTypeIndex(channelEntries).get(candidate.type); if (channelTypeEntries) { @@ -463,11 +486,11 @@ export function discoverAuthProfileSecretTargets( targetIds?: Iterable, ): DiscoveredConfigSecretTarget[] { const allowedTargetIds = normalizeAllowedTargetIds(targetIds); - const registryState = getCompiledSecretTargetRegistryState(); + const registryState = getCompiledCoreAuthProfileTargetState(); const discoveryEntries = resolveDiscoveryEntries({ allowedTargetIds, - defaultEntries: registryState.authProfilesCompiledSecretTargets, - entriesById: registryState.authProfilesTargetsById, + defaultEntries: registryState.entries, + entriesById: registryState.entriesById, }); return discoverSecretTargetsFromEntries(store, discoveryEntries); } @@ -476,7 +499,7 @@ export function discoverAuthProfileSecretTargets( * Lists auth-profile target entries that participate in plaintext/unresolved-ref audit. */ export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] { - return getCompiledSecretTargetRegistryState().compiledSecretTargetRegistry.filter( + return getCoreSecretTargetRegistry().filter( (entry) => entry.configFile === "auth-profiles.json" && entry.includeInAudit, ); } diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index bfa7ddad5440..0174fb8a239d 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -121,4 +121,14 @@ describe("secret target registry fast path", () => { expect(target?.entry.id).toBe("channels.telegram.botToken"); expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); }); + + it("resolves auth-profile plan targets without loading plugin metadata", () => { + const target = resolvePlanTargetAgainstRegistry({ + type: "auth-profiles.api_key.key", + pathSegments: ["profiles", "openai:default", "key"], + }); + + expect(target?.entry.id).toBe("auth-profiles.api_key.key"); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/security/install-policy.test.ts b/src/security/install-policy.test.ts index 05ee935341e4..3f29040a62ef 100644 --- a/src/security/install-policy.test.ts +++ b/src/security/install-policy.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { killPidIfAlive, @@ -223,9 +223,20 @@ describe("runInstallPolicy", () => { const forkScriptPath = await writeForkingNoOutputScript(sourceDir); const pidPath = path.join(sourceDir, "forked.pid"); let childPid: number | undefined; + const nativeSetTimeout = globalThis.setTimeout; + const noOutputTimeouts: Array<() => void> = []; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback, delay, ...args) => { + if (delay === 1_000) { + noOutputTimeouts.push(() => callback(...args)); + return nativeSetTimeout(() => undefined, 60_000); + } + return nativeSetTimeout(callback, delay, ...args); + }); try { - const result = await runInstallPolicy({ + const resultPromise = runInstallPolicy({ config: { security: { installPolicy: { @@ -235,10 +246,9 @@ describe("runInstallPolicy", () => { command: forkScriptPath, env: { NODE_BINARY: process.execPath, PID_FILE: pidPath }, allowInsecurePath: true, - // The first no-output window must absorb shell spawn latency - // under parallel-suite load; the script's readiness byte then - // pins the killing silence window after the pid write. - noOutputTimeoutMs: 1000, + // Preserve production-like startup headroom; the test fires + // the re-armed timer only after the readiness byte arrives. + noOutputTimeoutMs: 1_000, timeoutMs: 10_000, }, }, @@ -246,11 +256,17 @@ describe("runInstallPolicy", () => { }, request: baseRequest(sourceDir), }); + await vi.waitFor(() => { + expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2); + }); + childPid = await readPidFile(pidPath); + noOutputTimeouts.at(-1)?.(); + const result = await resultPromise; expect(result?.blocked?.reason).toContain("policy command produced no output"); - childPid = await readPidFile(pidPath); expect(await waitForPidToExit(childPid, 5_000)).toBe(true); } finally { + setTimeoutSpy.mockRestore(); killPidIfAlive(childPid); } }, diff --git a/test/scripts/bench-gateway-restart.test.ts b/test/scripts/bench-gateway-restart.test.ts index 1068e2c86755..b95ad21186c0 100644 --- a/test/scripts/bench-gateway-restart.test.ts +++ b/test/scripts/bench-gateway-restart.test.ts @@ -34,6 +34,8 @@ function readRestartIntentRow(env: NodeJS.ProcessEnv) { describe("gateway restart benchmark script", () => { let helpResult: ReturnType; + let unknownArgsResult: ReturnType; + let duplicateCaseResult: ReturnType; beforeAll(() => { helpResult = spawnSync( @@ -48,6 +50,38 @@ describe("gateway restart benchmark script", () => { }, }, ); + unknownArgsResult = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/bench-gateway-restart.ts", "--wat"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + }, + }, + ); + duplicateCaseResult = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-gateway-restart.ts", + "--case", + "skipChannels", + "--case", + "skipChannels", + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + }, + }, + ); }); it("prints help without running benchmark cases", () => { @@ -104,51 +138,17 @@ describe("gateway restart benchmark script", () => { }); it("rejects unknown benchmark CLI args before checking platform or running cases", () => { - const result = spawnSync( - process.execPath, - ["--import", "tsx", "scripts/bench-gateway-restart.ts", "--wat"], - { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - NODE_NO_WARNINGS: "1", - }, - }, - ); - - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("Unknown argument: --wat"); - expect(result.stderr).not.toContain("\n at "); + expect(unknownArgsResult.status).toBe(1); + expect(unknownArgsResult.stdout).toBe(""); + expect(unknownArgsResult.stderr.trim()).toBe("Unknown argument: --wat"); + expect(unknownArgsResult.stderr).not.toContain("\n at "); }); it("reports duplicate benchmark cases without a stack trace", () => { - const result = spawnSync( - process.execPath, - [ - "--import", - "tsx", - "scripts/bench-gateway-restart.ts", - "--case", - "skipChannels", - "--case", - "skipChannels", - ], - { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - NODE_NO_WARNINGS: "1", - }, - }, - ); - - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe('Duplicate --case "skipChannels"'); - expect(result.stderr).not.toContain("\n at "); + expect(duplicateCaseResult.status).toBe(1); + expect(duplicateCaseResult.stdout).toBe(""); + expect(duplicateCaseResult.stderr.trim()).toBe('Duplicate --case "skipChannels"'); + expect(duplicateCaseResult.stderr).not.toContain("\n at "); }); it("guards the SIGUSR1 restart benchmark on Windows", () => { diff --git a/test/scripts/issue-78851-model-resolution.test.ts b/test/scripts/issue-78851-model-resolution.test.ts index c534234db12a..4683b9d90e5d 100644 --- a/test/scripts/issue-78851-model-resolution.test.ts +++ b/test/scripts/issue-78851-model-resolution.test.ts @@ -1,5 +1,4 @@ // Issue 78851 profiler CLI tests cover argument handling before work starts. -import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { issue78851ModelResolutionHelpRequested, @@ -37,20 +36,9 @@ describe("issue 78851 model resolution profiler CLI", () => { }); it("rejects invalid arguments even when help is also requested", () => { - const result = spawnSync( - process.execPath, - [ - "--import", - "tsx", - "scripts/perf/issue-78851-model-resolution.ts", - "--wat", - "--help", - ], - { encoding: "utf8" }, + expect(() => parseIssue78851ModelResolutionOptions(["--wat", "--help"])).toThrow( + "Unknown argument: --wat", ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("Unknown argument: --wat"); }); it("rejects duplicate value flags before starting the profiler", () => { diff --git a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts index 61d9a852a3dc..fb2bc2f82e6d 100644 --- a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts +++ b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts @@ -359,6 +359,17 @@ describe("prepare-extension-package-boundary-artifacts", () => { tempRoots.add(rootDir); const descendantPidPath = path.join(rootDir, "descendant.pid"); let descendantPid = 0; + const nativeSetTimeout = globalThis.setTimeout; + let triggerStepTimeout: (() => void) | undefined; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback, timeout, ...args) => { + if (timeout === 2_000 && !triggerStepTimeout) { + triggerStepTimeout = () => callback(...args); + return nativeSetTimeout(() => undefined, 60_000); + } + return nativeSetTimeout(callback, timeout, ...args); + }); const descendantScript = [ "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", @@ -372,17 +383,20 @@ describe("prepare-extension-package-boundary-artifacts", () => { ].join("\n"); try { - // The parent records the descendant pid immediately after spawn, so this - // needs headroom for only one Node boot under parallel-suite load. + // The parent records the descendant pid at spawn time, before it + // boots; fire the captured production timeout after that readiness proof. const command = runNodeStep("hung-group-prep", ["--eval", parentScript], 2_000); const expectedFailure = expect(command).rejects.toThrow( "hung-group-prep timed out after 2000ms", ); descendantPid = Number.parseInt(await waitForFile(descendantPidPath, 4_000), 10); + expect(triggerStepTimeout).toBeDefined(); + triggerStepTimeout?.(); await expectedFailure; await waitForDead(descendantPid, 2_000); } finally { + setTimeoutSpy.mockRestore(); if (descendantPid && isProcessAlive(descendantPid)) { process.kill(descendantPid, "SIGKILL"); } diff --git a/test/scripts/test-group-report.test.ts b/test/scripts/test-group-report.test.ts index 7974be696680..d2edb8895b1d 100644 --- a/test/scripts/test-group-report.test.ts +++ b/test/scripts/test-group-report.test.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { buildGroupedTestComparison, buildGroupedTestReport, @@ -343,6 +343,62 @@ describe("scripts/test-group-report aggregation", () => { } }); + it("continues allow-failures profiling after a config writes an empty JSON report", async () => { + const tempDir = makeTempDir(); + try { + const result = await runReportPlans({ + args: parseTestGroupReportArgs([ + "--config", + "failed.config.ts", + "--config", + "passed.config.ts", + "--allow-failures", + "--no-rss", + ]), + logDir: path.join(tempDir, "logs"), + reportDir: path.join(tempDir, "reports"), + runPlans: [ + { config: "failed.config.ts", forwardedArgs: [], label: "failed" }, + { config: "passed.config.ts", forwardedArgs: [], label: "passed" }, + ], + runVitestJsonReport: async (params: { + config: string; + label: string; + logPath: string; + reportPath: string; + }) => { + fs.mkdirSync(path.dirname(params.reportPath), { recursive: true }); + fs.writeFileSync( + params.reportPath, + `${JSON.stringify({ + testResults: params.label === "failed" ? [] : [{ name: "passed.test.ts" }], + })}\n`, + "utf8", + ); + return { + config: params.config, + elapsedMs: 10, + label: params.label, + logPath: params.logPath, + maxRssBytes: null, + reportPath: params.reportPath, + status: params.label === "failed" ? 1 : 0, + }; + }, + }); + + expect(result.failed).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.runs.map((run) => [run.label, run.status])).toStrictEqual([ + ["failed", 1], + ["passed", 0], + ]); + expect(result.runEntries.map((entry) => entry.config)).toStrictEqual(["passed"]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("prints slow tests as soon as each config report completes", async () => { const tempDir = makeTempDir(); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -1179,6 +1235,24 @@ describe("scripts/test-group-report child process guard", () => { }); describe("scripts/test-group-report run plans", () => { + let serialFullSuitePlans: ReturnType = []; + let parallelFullSuitePlans: ReturnType = []; + + beforeAll(() => { + withEnv( + { + OPENCLAW_TEST_PROJECTS_PARALLEL: undefined, + OPENCLAW_TEST_PROJECTS_LEAF_SHARDS: undefined, + }, + () => { + serialFullSuitePlans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"])); + }, + ); + withEnv({ OPENCLAW_TEST_PROJECTS_PARALLEL: "6" }, () => { + parallelFullSuitePlans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"])); + }); + }); + it("isolates full-suite duration reports by default", () => { expect(resolveReportVitestArgs(parseTestGroupReportArgs(["--full-suite"]))).toEqual([ "--isolate=true", @@ -1258,40 +1332,27 @@ describe("scripts/test-group-report run plans", () => { }); it("uses leaf configs for full-suite profiling without requiring parallel env", () => { - withEnv( - { - OPENCLAW_TEST_PROJECTS_PARALLEL: undefined, - OPENCLAW_TEST_PROJECTS_LEAF_SHARDS: undefined, - }, - () => { - const plans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"])); - - expect(plans.map((plan) => plan.config)).not.toContain( - "test/vitest/vitest.full-agentic.config.ts", - ); - expect(plans.map((plan) => plan.config)).toContain( - "test/vitest/vitest.agents-tools.config.ts", - ); - }, + expect(serialFullSuitePlans.map((plan) => plan.config)).not.toContain( + "test/vitest/vitest.full-agentic.config.ts", + ); + expect(serialFullSuitePlans.map((plan) => plan.config)).toContain( + "test/vitest/vitest.agents-tools.config.ts", ); }); it("preserves full-suite shard file args and unique report labels", () => { - withEnv({ OPENCLAW_TEST_PROJECTS_PARALLEL: "6" }, () => { - const plans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"])); - const gatewayServerPlans = plans.filter( - (plan) => plan.config === "test/vitest/vitest.gateway-server.config.ts", - ); + const gatewayServerPlans = parallelFullSuitePlans.filter( + (plan) => plan.config === "test/vitest/vitest.gateway-server.config.ts", + ); - expect(gatewayServerPlans.length).toBeGreaterThan(1); - expect(new Set(gatewayServerPlans.map((plan) => plan.label)).size).toBe( - gatewayServerPlans.length, - ); - expect(gatewayServerPlans.every((plan) => plan.forwardedArgs.length > 0)).toBe(true); - expect(gatewayServerPlans.flatMap((plan) => plan.forwardedArgs)).toContain( - "src/gateway/server.node-pairing-authz.test.ts", - ); - }); + expect(gatewayServerPlans.length).toBeGreaterThan(1); + expect(new Set(gatewayServerPlans.map((plan) => plan.label)).size).toBe( + gatewayServerPlans.length, + ); + expect(gatewayServerPlans.every((plan) => plan.forwardedArgs.length > 0)).toBe(true); + expect(gatewayServerPlans.flatMap((plan) => plan.forwardedArgs)).toContain( + "src/gateway/server.node-pairing-authz.test.ts", + ); }); }); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 6088371ce107..6564fc5e0cd2 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -4130,19 +4130,23 @@ describe("scripts/test-projects full-suite sharding", () => { it("can expand full-suite shards to project configs for perf experiments", () => { const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts"; + const toolingConfig = "test/vitest/vitest.tooling.config.ts"; + const unitFastConfig = "test/vitest/vitest.unit-fast.config.ts"; const plans = leafShardPlans; + const toolingPlans = plans.filter((plan) => plan.config === toolingConfig); + const unitFastPlans = plans.filter((plan) => plan.config === unitFastConfig); if (leafShardHasGitGatewayListing) { expect(leafShardGatewayTreeReads).toEqual([]); } expect(leafShardPlans.map((plan) => plan.config)).toEqual([ - "test/vitest/vitest.unit-fast.config.ts", + ...unitFastPlans.map(() => unitFastConfig), "test/vitest/vitest.unit-fast-fake-timers.config.ts", "test/vitest/vitest.unit-src.config.ts", "test/vitest/vitest.unit-security.config.ts", "test/vitest/vitest.unit-support.config.ts", "test/vitest/vitest.boundary.config.ts", - "test/vitest/vitest.tooling.config.ts", + ...toolingPlans.map(() => toolingConfig), "test/vitest/vitest.tooling-docker.config.ts", "test/vitest/vitest.tooling-isolated.config.ts", "test/vitest/vitest.contracts-channel-surface.config.ts", @@ -4235,9 +4239,36 @@ describe("scripts/test-projects full-suite sharding", () => { expect(gatewayTargets).toContain("src/gateway/server-network-runtime.e2e.test.ts"); expect(gatewayTargets).not.toContain("src/gateway/gateway.test.ts"); expect(Math.max(...gatewayChunkSizes) - Math.min(...gatewayChunkSizes)).toBeLessThanOrEqual(1); - expect(plans.filter((plan) => plan.config !== gatewayServerConfig)).toEqual( + const unitFastTargets = unitFastPlans.flatMap((plan) => plan.forwardedArgs); + expect(unitFastPlans.length).toBeGreaterThan(10); + expect(unitFastPlans.every((plan) => plan.forwardedArgs.length <= 70)).toBe(true); + expect(unitFastTargets.length).toBeGreaterThan(1_000); + expect(new Set(unitFastTargets).size).toBe(unitFastTargets.length); + expect(unitFastTargets).toContain("extensions/canvas/src/host/server.state-dir.test.ts"); + expect(unitFastTargets).not.toContain("src/utils.test.ts"); + const toolingTargets = toolingPlans.flatMap((plan) => plan.forwardedArgs); + expect(toolingPlans.length).toBeGreaterThan(1); + expect(toolingPlans.every((plan) => plan.forwardedArgs.length <= 2)).toBe(true); + expect(new Set(toolingTargets).size).toBe(toolingTargets.length); + expect(toolingTargets).toContain("test/scripts/test-group-report.test.ts"); + expect(toolingTargets).toContain("src/scripts/control-ui-i18n-report.test.ts"); + expect(toolingTargets).not.toContain("test/scripts/docker-build-helper.test.ts"); + expect(toolingTargets).not.toContain("test/scripts/openclaw-e2e-instance.test.ts"); + expect( + plans.filter( + (plan) => + plan.config !== gatewayServerConfig && + plan.config !== toolingConfig && + plan.config !== unitFastConfig, + ), + ).toEqual( plans - .filter((plan) => plan.config !== gatewayServerConfig) + .filter( + (plan) => + plan.config !== gatewayServerConfig && + plan.config !== toolingConfig && + plan.config !== unitFastConfig, + ) .map((plan) => ({ config: plan.config, forwardedArgs: [],