diff --git a/extensions/deepinfra/provider-models.test.ts b/extensions/deepinfra/provider-models.test.ts index ef25277bc710..d0c67e86b186 100644 --- a/extensions/deepinfra/provider-models.test.ts +++ b/extensions/deepinfra/provider-models.test.ts @@ -227,7 +227,7 @@ describe("hasDeepInfraApiKey", () => { describe("discoverDeepInfraModels (chat-only shim)", () => { it("returns static catalog in test environment", async () => { - const models = await discoverDeepInfraModels(); + const models = await discoverDeepInfraModels({ env: { VITEST: "true" } }); const modelIds = models.map((m) => m.id); const streamingUsageIncompatibleModelIds = models .filter((m) => !m.compat?.supportsUsageInStreaming) diff --git a/extensions/deepinfra/provider-models.ts b/extensions/deepinfra/provider-models.ts index c602f69031be..20aa29b85b0c 100644 --- a/extensions/deepinfra/provider-models.ts +++ b/extensions/deepinfra/provider-models.ts @@ -411,11 +411,11 @@ export async function discoverDeepInfraSurfaces(options?: { env?: NodeJS.ProcessEnv; agentDir?: string; }): Promise { - if (process.env.NODE_ENV === "test" || process.env.VITEST) { + const env = options?.env ?? process.env; + if (env.NODE_ENV === "test" || env.VITEST) { return manifestFallbackCatalog(); } - const env = options?.env ?? process.env; const hasKey = options?.hasApiKey ?? hasDeepInfraApiKey({ env, agentDir: options?.agentDir }); if (!hasKey) { return manifestFallbackCatalog(); @@ -468,6 +468,11 @@ export async function discoverDeepInfraModels(options?: { agentDir?: string; }): Promise { const catalog = await discoverDeepInfraSurfaces(options); + if (!catalog.live) { + // Keep manifest-owned chat compatibility metadata intact. The generic + // surface projection intentionally carries only cross-surface fields. + return DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition); + } const chatModels = catalog.chat.length > 0 ? catalog.chat : [...catalog.chat, ...catalog.vlm]; if (chatModels.length === 0) { // True empty (no manifest entries either) — keep behavior stable. diff --git a/extensions/kimi-coding/implicit-provider.test.ts b/extensions/kimi-coding/implicit-provider.test.ts index b94a7343433d..9b02eb75224a 100644 --- a/extensions/kimi-coding/implicit-provider.test.ts +++ b/extensions/kimi-coding/implicit-provider.test.ts @@ -54,7 +54,8 @@ describe("Kimi implicit provider (#22409)", () => { headers: { "User-Agent": "claude-code/0.1.0", }, - models: [ + // Credential-aware catalog assembly may prioritize the configured default. + models: expect.arrayContaining([ { id: "kimi-for-coding", name: "Kimi Code", @@ -111,9 +112,10 @@ describe("Kimi implicit provider (#22409)", () => { contextWindow: 262144, maxTokens: 131072, }, - ], + ]), apiKey: "test-key", }); + expect(provider.models).toHaveLength(4); }); it("ignores retired kimi-coding provider overrides", async () => { diff --git a/extensions/memory-core/src/memory/manager.legacy-migration-cleanup.test.ts b/extensions/memory-core/src/memory/manager.legacy-migration-cleanup.test.ts index 09b77acdf316..efc5e483bcb9 100644 --- a/extensions/memory-core/src/memory/manager.legacy-migration-cleanup.test.ts +++ b/extensions/memory-core/src/memory/manager.legacy-migration-cleanup.test.ts @@ -15,8 +15,7 @@ import { } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./test-runtime-mocks.js"; -import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js"; -import type { MemoryIndexManager } from "./manager.js"; +import { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; @@ -35,7 +34,7 @@ describe("memory legacy migration cleanup", () => { afterEach(async () => { await manager?.close(); manager = undefined; - await closeAllMemorySearchManagers(); + await closeAllMemoryIndexManagers(); closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); if (originalStateDir === undefined) { @@ -160,11 +159,11 @@ describe("memory legacy migration cleanup", () => { }, }) as OpenClawConfig; const cfg = createConfig({ provider: "none", vectorEnabled: false }); - const result = await getMemorySearchManager({ cfg, agentId: "main" }); - if (!result.manager) { - throw new Error(result.error ?? "memory manager missing"); + const result = await MemoryIndexManager.get({ cfg, agentId: "main" }); + if (!result) { + throw new Error("memory manager missing"); } - manager = result.manager as unknown as MemoryIndexManager; + manager = result; expect(manager.status().fts?.available).toBe(true); expect(Reflect.get(manager, "sessionsFullRetryDirty")).toBe(false); @@ -248,22 +247,25 @@ describe("memory legacy migration cleanup", () => { } finally { observerDb.close(); } - await closeAllMemorySearchManagers(); - manager = undefined; - - const reloadResult = await getMemorySearchManager({ - cfg: createConfig({ - extensionPath: vectorExtensionPath, - provider: "openai", - vectorEnabled: true, - }), - agentId: "main", + // Exercise the later vector-enabled load directly. Recreating the public + // manager here also tests unrelated provider/cache retirement lifecycles. + const vectorState = Reflect.get(manager, "vector") as { + available: boolean | null; + enabled: boolean; + extensionPath?: string; + }; + const vectorDb = new DatabaseSync(dbPath, { allowExtension: true }); + const managerLoaded = await loadSqliteVecExtension({ + db: vectorDb, + extensionPath: vectorExtensionPath, }); - if (!reloadResult.manager) { - throw new Error(reloadResult.error ?? "reloaded memory manager missing"); - } - manager = reloadResult.manager as unknown as MemoryIndexManager; - const reloadedDb = Reflect.get(manager, "db") as DatabaseSync; + expect(managerLoaded.ok, managerLoaded.error).toBe(true); + db.close(); + Reflect.set(manager, "db", vectorDb); + vectorState.enabled = true; + vectorState.available = true; + vectorState.extensionPath = vectorExtensionPath; + Reflect.set(manager, "vectorReady", null); await expect( ( manager as unknown as { @@ -271,12 +273,14 @@ describe("memory legacy migration cleanup", () => { } ).loadVectorExtension(), ).resolves.toBe(false); - expect(reloadedDb.prepare("SELECT vec_version() AS version").get()).toEqual({ + expect(vectorDb.prepare("SELECT vec_version() AS version").get()).toEqual({ version: expect.any(String), }); - expect( - reloadedDb.prepare("SELECT COUNT(*) AS count FROM memory_index_chunks_vec").get(), - ).toEqual({ count: 2 }); + expect(vectorDb.prepare("SELECT COUNT(*) AS count FROM memory_index_chunks_vec").get()).toEqual( + { + count: 2, + }, + ); expect(Reflect.get(manager, "memoryFullRetryDirty")).toBe(true); }); }); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index 5561496d38bf..645dc42af266 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -9,6 +9,19 @@ import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js"; import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js"; const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn()); +const runChannelInboundEventMock = vi.hoisted(() => + vi.fn(async () => ({ dispatched: false }) as never), +); + +vi.mock("openclaw/plugin-sdk/channel-inbound", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/channel-inbound", + ); + return { + ...actual, + runChannelInboundEvent: runChannelInboundEventMock, + }; +}); vi.mock("./auto-reply/monitor/last-route.js", async () => { const actual = await vi.importActual( @@ -107,6 +120,7 @@ describe("web auto-reply last-route", () => { beforeEach(() => { updateLastRouteInBackgroundMock.mockClear(); + runChannelInboundEventMock.mockClear(); }); it("updates last-route for direct chats without senderE164", async () => { diff --git a/packages/memory-host-sdk/src/host/embeddings-worker.lifecycle.test.ts b/packages/memory-host-sdk/src/host/embeddings-worker.lifecycle.test.ts index d2487269ba1f..f69c259421ff 100644 --- a/packages/memory-host-sdk/src/host/embeddings-worker.lifecycle.test.ts +++ b/packages/memory-host-sdk/src/host/embeddings-worker.lifecycle.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { beforeEach, expect, it, vi } from "vitest"; const forkMock = vi.hoisted(() => vi.fn()); +const accessMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); @@ -12,10 +13,75 @@ vi.mock("node:child_process", async () => { }; }); +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + default: { ...actual, access: accessMock }, + access: accessMock, + }; +}); + import { createLocalEmbeddingWorkerProvider } from "./embeddings-worker.js"; beforeEach(() => { forkMock.mockReset(); + accessMock.mockReset().mockRejectedValue(new Error("missing")); +}); + +it("forks workers through a stable Homebrew Node path", async () => { + const originalExecPath = process.execPath; + Object.defineProperty(process, "execPath", { + configurable: true, + value: "/opt/homebrew/Cellar/node/26.5.0/bin/node", + }); + accessMock.mockImplementation(async (candidate: string) => { + if (candidate === "/opt/homebrew/opt/node/bin/node") { + return; + } + throw new Error("missing"); + }); + const child = Object.assign(new EventEmitter(), { + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + disconnect: vi.fn(function (this: { connected: boolean }) { + this.connected = false; + }), + kill: vi.fn(function (this: EventEmitter, signal: NodeJS.Signals) { + queueMicrotask(() => this.emit("close", null, signal)); + return true; + }), + send: vi.fn(function ( + this: EventEmitter, + message: { id: number }, + callback: (err?: Error | null) => void, + ) { + callback(); + queueMicrotask(() => this.emit("message", { id: message.id, ok: true })); + return true; + }), + }); + forkMock.mockReturnValue(child); + + try { + const provider = await createLocalEmbeddingWorkerProvider( + { config: {} as never, provider: "local", model: "", fallback: "none" }, + { workerScriptPath: "/mock/worker.cjs" }, + ); + + expect(forkMock).toHaveBeenCalledWith( + "/mock/worker.cjs", + [], + expect.objectContaining({ execPath: "/opt/homebrew/opt/node/bin/node" }), + ); + await expect(provider.close?.()).resolves.toBeUndefined(); + } finally { + Object.defineProperty(process, "execPath", { + configurable: true, + value: originalExecPath, + }); + } }); it("keeps an active worker alive when a queued embedding request is aborted", async () => { diff --git a/packages/memory-host-sdk/src/host/embeddings-worker.ts b/packages/memory-host-sdk/src/host/embeddings-worker.ts index 26dbffd54173..3aef64c5bf18 100644 --- a/packages/memory-host-sdk/src/host/embeddings-worker.ts +++ b/packages/memory-host-sdk/src/host/embeddings-worker.ts @@ -1,8 +1,10 @@ // Memory Host SDK module implements embeddings worker behavior. import { fork, type ChildProcess } from "node:child_process"; +import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; +import { stableHomebrewNodePathCandidates } from "@openclaw/normalization-core/stable-node-path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { DEFAULT_LOCAL_MODEL } from "./embedding-defaults.js"; import { @@ -233,6 +235,18 @@ function resolveWorkerExecArgv(): string[] { return args; } +async function resolveWorkerExecPath(nodePath: string): Promise { + for (const candidate of stableHomebrewNodePathCandidates(nodePath)) { + try { + await fs.access(candidate); + return candidate; + } catch { + // Try the next Homebrew-managed stable path. + } + } + return nodePath; +} + /** IPC client that serializes local embedding calls through one child process. */ class LocalEmbeddingWorkerClient { private child: ChildProcess | null = null; @@ -243,7 +257,10 @@ class LocalEmbeddingWorkerClient { private pending = new Map(); private lastRuntimeFacts: LocalEmbeddingRuntimeFacts | undefined; - constructor(private readonly scriptPath: string) {} + constructor( + private readonly scriptPath: string, + private readonly execPath: string, + ) {} /** Start or reuse the child worker and initialize its provider. */ async initialize(options: EmbeddingProviderOptions): Promise { @@ -340,6 +357,7 @@ class LocalEmbeddingWorkerClient { } const child = fork(this.scriptPath, [], { + execPath: this.execPath, execArgv: resolveWorkerExecArgv(), serialization: "json", stdio: ["ignore", "ignore", "ignore", "ipc"], @@ -653,8 +671,12 @@ async function createLocalEmbeddingWorkerProviderOnce( ): Promise { const modelPath = normalizeOptionalString(options.local?.modelPath) || DEFAULT_LOCAL_MODEL; const workerOptions = serializeLocalEmbeddingOptions(options, runtimeOptions); + // Resolve before constructing the client so worker restarts stay synchronous. + // The stable Homebrew symlink can retarget without changing this stored path. + const workerExecPath = await resolveWorkerExecPath(process.execPath); const client = new LocalEmbeddingWorkerClient( runtimeOptions?.workerScriptPath ?? resolveDefaultWorkerScriptPath(), + workerExecPath, ); try { await client.initialize(workerOptions); diff --git a/packages/normalization-core/package.json b/packages/normalization-core/package.json index 84fe0befb1eb..da1d598cd17c 100644 --- a/packages/normalization-core/package.json +++ b/packages/normalization-core/package.json @@ -69,6 +69,11 @@ "import": "./dist/string-normalization.mjs", "default": "./dist/string-normalization.mjs" }, + "./stable-node-path": { + "types": "./dist/stable-node-path.d.mts", + "import": "./dist/stable-node-path.mjs", + "default": "./dist/stable-node-path.mjs" + }, "./utf16-slice": { "types": "./dist/utf16-slice.d.mts", "import": "./dist/utf16-slice.mjs", @@ -76,7 +81,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean" }, "dependencies": { "libphonenumber-js": "1.13.9" diff --git a/packages/normalization-core/src/stable-node-path.ts b/packages/normalization-core/src/stable-node-path.ts new file mode 100644 index 000000000000..d5100f5e212c --- /dev/null +++ b/packages/normalization-core/src/stable-node-path.ts @@ -0,0 +1,25 @@ +import path from "node:path"; +import { expectDefined } from "./expect.js"; + +/** + * Returns stable Homebrew paths for a versioned Cellar Node executable. + * Availability remains caller-owned so packages can reuse the path contract + * without importing another package's filesystem/runtime layer. + */ +export function stableHomebrewNodePathCandidates(nodePath: string): string[] { + const cellarMatch = nodePath.match( + /^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/, + ); + if (!cellarMatch) { + return []; + } + + const prefix = expectDefined(cellarMatch[1], "cellar match capture group 1"); + const formula = expectDefined(cellarMatch[2], "cellar match capture group 2"); + const pathModule = nodePath.includes("\\") ? path.win32 : path.posix; + const candidates = [pathModule.join(prefix, "opt", formula, "bin", "node")]; + if (formula === "node") { + candidates.push(pathModule.join(prefix, "bin", "node")); + } + return candidates; +} diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index 380902239e27..96f335ace247 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -24,6 +24,7 @@ import { import { applyDefaultMultiSpecVitestCachePaths, applyDefaultVitestNoOutputTimeout, + applyFullExtensionsHeapBudget, applyParallelVitestCachePaths, buildFullSuiteVitestRunPlans, createVitestPreflightPnpmArgs, @@ -299,7 +300,12 @@ async function main() { cwd: process.cwd(), }); const runSpecs = applyDefaultMultiSpecVitestCachePaths( - applyDefaultVitestNoOutputTimeout(rawRunSpecs, { env: baseEnv }), + applyDefaultVitestNoOutputTimeout( + applyFullExtensionsHeapBudget(rawRunSpecs, { env: baseEnv }), + { + env: baseEnv, + }, + ), { cwd: process.cwd(), env: baseEnv }, ); diff --git a/scripts/test-projects.test-support.d.mts b/scripts/test-projects.test-support.d.mts index f66c657b0d9b..5e76d0567c30 100644 --- a/scripts/test-projects.test-support.d.mts +++ b/scripts/test-projects.test-support.d.mts @@ -76,6 +76,11 @@ export function resolveParallelFullSuiteConcurrency( hostInfo?: VitestHostInfo, ): number; +export function applyFullExtensionsHeapBudget( + specs: T[], + params?: { env?: Record }, +): Array & { env: NodeJS.ProcessEnv }>; + export function resolveChangedTargetArgs( args: string[], cwd?: string, diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 7fe89be37000..bffc6273240c 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -4850,6 +4850,45 @@ function hasConservativeVitestWorkerBudget(env) { return workerBudget !== null && workerBudget <= 1; } +const FULL_EXTENSIONS_CONFIG = "test/vitest/vitest.full-extensions.config.ts"; +const FULL_EXTENSIONS_MIN_HEAP_MB = 8192; + +function ensureMaxOldSpaceSize(nodeOptions, minimumMb) { + const normalized = nodeOptions?.trim() ?? ""; + const matches = Array.from( + normalized.matchAll(/(^|\s)--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)(?=\s|$)/gu), + ); + const match = matches.at(-1); + if (!match) { + return [normalized, `--max-old-space-size=${minimumMb}`].filter(Boolean).join(" "); + } + const currentMb = Number(match[2]); + if (Number.isSafeInteger(currentMb) && currentMb >= minimumMb) { + return normalized; + } + const start = match.index; + const replacement = match[0].replace(/\d+$/u, String(minimumMb)); + return `${normalized.slice(0, start)}${replacement}${normalized.slice(start + match[0].length)}`; +} + +export function applyFullExtensionsHeapBudget(specs, params = {}) { + const baseEnv = params.env ?? {}; + return specs.map((spec) => + spec.config === FULL_EXTENSIONS_CONFIG + ? { + ...spec, + env: { + ...spec.env, + NODE_OPTIONS: ensureMaxOldSpaceSize( + spec.env?.NODE_OPTIONS ?? baseEnv.NODE_OPTIONS, + FULL_EXTENSIONS_MIN_HEAP_MB, + ), + }, + } + : spec, + ); +} + export function resolveParallelFullSuiteConcurrency(specCount, envInput, hostInfo) { let env = envInput; env ??= process.env; diff --git a/src/cli/help-exit.process.test.ts b/src/cli/help-exit.process.test.ts index df64095ae8c2..0634b9801e1d 100644 --- a/src/cli/help-exit.process.test.ts +++ b/src/cli/help-exit.process.test.ts @@ -13,8 +13,9 @@ import { registerSubCliByName } from "./program/register.subclis.js"; const execFileAsync = promisify(execFile); const tempDirs = useAutoCleanupTempDirTracker(afterEach); -// Fork CI uses shared hosted runners where cold TSX startup can exceed 45 seconds. -const CHILD_PROCESS_TIMEOUT_MS = 75_000; +// This is a deadlock guard, not a startup SLO. Fork CI can take over a minute +// to cold-load the CLI graph on shared hosted runners, while still exiting correctly. +const CHILD_PROCESS_TIMEOUT_MS = 120_000; const LAZY_GROUP_HELP_CASES = [ { group: "backup", usageCommand: "backup", registry: "core" }, { group: "capability", usageCommand: "infer|capability", registry: "subcli" }, diff --git a/src/infra/stable-node-path.ts b/src/infra/stable-node-path.ts index 1cbba25df8c8..c11281f70955 100644 --- a/src/infra/stable-node-path.ts +++ b/src/infra/stable-node-path.ts @@ -1,7 +1,6 @@ // Resolves Homebrew Node binary paths to stable symlink targets. import fs from "node:fs/promises"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; +import { stableHomebrewNodePathCandidates } from "@openclaw/normalization-core/stable-node-path"; /** * Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node) @@ -11,35 +10,13 @@ import { expectDefined } from "@openclaw/normalization-core"; * - Versioned formula "node@22": /opt/node@22/bin/node (keg-only) */ export async function resolveStableNodePath(nodePath: string): Promise { - const cellarMatch = nodePath.match( - /^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/, - ); - if (!cellarMatch) { - return nodePath; - } - const prefix = expectDefined(cellarMatch[1], "cellar match capture group 1"); // e.g. /opt/homebrew - const formula = expectDefined(cellarMatch[2], "cellar match capture group 2"); // e.g. "node" or "node@22" - const pathModule = nodePath.includes("\\") ? path.win32 : path.posix; - - // Try the Homebrew opt symlink first — works for both default and versioned formulas. - const optPath = pathModule.join(prefix, "opt", formula, "bin", "node"); - try { - await fs.access(optPath); - return optPath; - } catch { - // fall through - } - - // For the default "node" formula, also try the direct bin symlink. - if (formula === "node") { - const binPath = pathModule.join(prefix, "bin", "node"); + for (const candidate of stableHomebrewNodePathCandidates(nodePath)) { try { - await fs.access(binPath); - return binPath; + await fs.access(candidate); + return candidate; } catch { - // fall through + // Try the next Homebrew-managed stable path. } } - return nodePath; } diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index c27c762db74b..a073dd21375d 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -12,6 +12,7 @@ import { DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS, applyDefaultMultiSpecVitestCachePaths, applyDefaultVitestNoOutputTimeout, + applyFullExtensionsHeapBudget, applyParallelVitestCachePaths, buildFullSuiteVitestRunPlans, buildVitestArgs, @@ -4640,6 +4641,57 @@ describe("scripts/test-projects full-suite sharding", () => { } }); + it("gives only the aggregate extension shard an 8 GiB heap floor", () => { + const specs = applyFullExtensionsHeapBudget([ + { + config: "test/vitest/vitest.full-extensions.config.ts", + env: { NODE_OPTIONS: "--trace-warnings --max-old-space-size=4096" }, + }, + { + config: "test/vitest/vitest.full-core-runtime.config.ts", + env: { NODE_OPTIONS: "--max-old-space-size=4096" }, + }, + ]); + + expect(specs[0]?.env.NODE_OPTIONS).toBe("--trace-warnings --max-old-space-size=8192"); + expect(specs[1]?.env.NODE_OPTIONS).toBe("--max-old-space-size=4096"); + }); + + it("preserves a larger aggregate extension heap override", () => { + const specs = applyFullExtensionsHeapBudget([ + { + config: "test/vitest/vitest.full-extensions.config.ts", + env: { NODE_OPTIONS: "--max_old_space_size 12288 --trace-warnings" }, + }, + ]); + + expect(specs[0]?.env.NODE_OPTIONS).toBe("--max_old_space_size 12288 --trace-warnings"); + }); + + it("preserves inherited Node options when the spec has no override", () => { + const specs = applyFullExtensionsHeapBudget( + [{ config: "test/vitest/vitest.full-extensions.config.ts", env: {} }], + { + env: { + NODE_OPTIONS: "--require ./test-hook.cjs --max-old-space-size=12288", + }, + }, + ); + + expect(specs[0]?.env.NODE_OPTIONS).toBe("--require ./test-hook.cjs --max-old-space-size=12288"); + }); + + it("raises the effective last aggregate extension heap override", () => { + const specs = applyFullExtensionsHeapBudget([ + { + config: "test/vitest/vitest.full-extensions.config.ts", + env: { NODE_OPTIONS: "--max-old-space-size=12288 --max_old_space_size=4096" }, + }, + ]); + + expect(specs[0]?.env.NODE_OPTIONS).toBe("--max-old-space-size=12288 --max_old_space_size=8192"); + }); + it("keeps explicit parallel overrides ahead of the host-aware profile", () => { expect( resolveParallelFullSuiteConcurrency( diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 3d625873ec4e..64e63cce3f45 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -482,6 +482,16 @@ export const sharedVitestConfig = { find: "@openclaw/normalization-core/result", replacement: path.join(repoRoot, "packages", "normalization-core", "src", "result.ts"), }, + { + find: "@openclaw/normalization-core/stable-node-path", + replacement: path.join( + repoRoot, + "packages", + "normalization-core", + "src", + "stable-node-path.ts", + ), + }, { find: "@openclaw/normalization-core/string-coerce", replacement: path.join( diff --git a/tsconfig.json b/tsconfig.json index a1c9c4485ffd..d6e7f0a38439 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -164,6 +164,9 @@ "@openclaw/normalization-core/result": [ "./packages/normalization-core/src/result.ts" ], + "@openclaw/normalization-core/stable-node-path": [ + "./packages/normalization-core/src/stable-node-path.ts" + ], "@openclaw/normalization-core/string-coerce": [ "./packages/normalization-core/src/string-coerce.ts" ],