diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f9331b4b..4f10d31e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. - Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705) - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. +- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. ## 2026.2.6 diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 5d10d9bbac..d51eb66ad7 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -92,70 +92,28 @@ describe("memory plugin e2e", () => { }).toThrow("embedding.apiKey is required"); }); - test("shouldCapture filters correctly", async () => { - // Test the capture filtering logic by checking the rules - const triggers = [ - { text: "I prefer dark mode", shouldMatch: true }, - { text: "Remember that my name is John", shouldMatch: true }, - { text: "My email is test@example.com", shouldMatch: true }, - { text: "Call me at +1234567890123", shouldMatch: true }, - { text: "We decided to use TypeScript", shouldMatch: true }, - { text: "I always want verbose output", shouldMatch: true }, - { text: "Just a random short message", shouldMatch: false }, - { text: "x", shouldMatch: false }, // Too short - { text: "injected", shouldMatch: false }, // Skip injected - ]; + test("shouldCapture applies real capture rules", async () => { + const { shouldCapture } = await import("./index.js"); - // The shouldCapture function is internal, but we can test via the capture behavior - // For now, just verify the patterns we expect to match - for (const { text, shouldMatch } of triggers) { - const hasPreference = /prefer|radši|like|love|hate|want/i.test(text); - const hasRemember = /zapamatuj|pamatuj|remember/i.test(text); - const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text); - const hasPhone = /\+\d{10,}/.test(text); - const hasDecision = /rozhodli|decided|will use|budeme/i.test(text); - const hasAlways = /always|never|important/i.test(text); - const isInjected = text.includes(""); - const isTooShort = text.length < 10; - - const wouldCapture = - !isTooShort && - !isInjected && - (hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways); - - if (shouldMatch) { - expect(wouldCapture).toBe(true); - } - } + expect(shouldCapture("I prefer dark mode")).toBe(true); + expect(shouldCapture("Remember that my name is John")).toBe(true); + expect(shouldCapture("My email is test@example.com")).toBe(true); + expect(shouldCapture("Call me at +1234567890123")).toBe(true); + expect(shouldCapture("I always want verbose output")).toBe(true); + expect(shouldCapture("x")).toBe(false); + expect(shouldCapture("injected")).toBe(false); + expect(shouldCapture("status")).toBe(false); + expect(shouldCapture("Here is a short **summary**\n- bullet")).toBe(false); }); - test("detectCategory classifies correctly", async () => { - // Test category detection patterns - const cases = [ - { text: "I prefer dark mode", expected: "preference" }, - { text: "We decided to use React", expected: "decision" }, - { text: "My email is test@example.com", expected: "entity" }, - { text: "The server is running on port 3000", expected: "fact" }, - ]; + test("detectCategory classifies using production logic", async () => { + const { detectCategory } = await import("./index.js"); - for (const { text, expected } of cases) { - const lower = text.toLowerCase(); - let category: string; - - if (/prefer|radši|like|love|hate|want/i.test(lower)) { - category = "preference"; - } else if (/rozhodli|decided|will use|budeme/i.test(lower)) { - category = "decision"; - } else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) { - category = "entity"; - } else if (/is|are|has|have|je|má|jsou/i.test(lower)) { - category = "fact"; - } else { - category = "other"; - } - - expect(category).toBe(expected); - } + expect(detectCategory("I prefer dark mode")).toBe("preference"); + expect(detectCategory("We decided to use React")).toBe("decision"); + expect(detectCategory("My email is test@example.com")).toBe("entity"); + expect(detectCategory("The server is running on port 3000")).toBe("fact"); + expect(detectCategory("Random note")).toBe("other"); }); }); diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index ce26e33e91..9ee2e39077 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -195,7 +195,7 @@ const MEMORY_TRIGGERS = [ /always|never|important/i, ]; -function shouldCapture(text: string): boolean { +export function shouldCapture(text: string): boolean { if (text.length < 10 || text.length > 500) { return false; } @@ -219,7 +219,7 @@ function shouldCapture(text: string): boolean { return MEMORY_TRIGGERS.some((r) => r.test(text)); } -function detectCategory(text: string): MemoryCategory { +export function detectCategory(text: string): MemoryCategory { const lower = text.toLowerCase(); if (/prefer|radši|like|love|hate|want/i.test(lower)) { return "preference"; diff --git a/src/commands/onboard-non-interactive.ai-gateway.test.ts b/src/commands/onboard-non-interactive.ai-gateway.test.ts deleted file mode 100644 index 0b02632a51..0000000000 --- a/src/commands/onboard-non-interactive.ai-gateway.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -describe("onboard (non-interactive): Vercel AI Gateway", () => { - it("stores the API key and configures the default model", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-gateway-")); - process.env.HOME = tempHome; - process.env.OPENCLAW_STATE_DIR = tempHome; - process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); - vi.resetModules(); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - authChoice: "ai-gateway-api-key", - aiGatewayApiKey: "gateway-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const { CONFIG_PATH } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { - auth?: { - profiles?: Record; - }; - agents?: { defaults?: { model?: { primary?: string } } }; - }; - - expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.provider).toBe("vercel-ai-gateway"); - expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.mode).toBe("api_key"); - expect(cfg.agents?.defaults?.model?.primary).toBe( - "vercel-ai-gateway/anthropic/claude-opus-4.6", - ); - - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); - const store = ensureAuthProfileStore(); - const profile = store.profiles["vercel-ai-gateway:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.provider).toBe("vercel-ai-gateway"); - expect(profile.key).toBe("gateway-test-key"); - } - } finally { - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.OPENCLAW_STATE_DIR = prev.stateDir; - process.env.OPENCLAW_CONFIG_PATH = prev.configPath; - process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.OPENCLAW_SKIP_CRON = prev.skipCron; - process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; - process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); -}); diff --git a/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts b/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts deleted file mode 100644 index c6196317cf..0000000000 --- a/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -describe("onboard (non-interactive): Cloudflare AI Gateway", () => { - it("stores the API key and configures the default model", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-cf-gateway-")); - process.env.HOME = tempHome; - process.env.OPENCLAW_STATE_DIR = tempHome; - process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); - vi.resetModules(); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - authChoice: "cloudflare-ai-gateway-api-key", - cloudflareAiGatewayAccountId: "cf-account-id", - cloudflareAiGatewayGatewayId: "cf-gateway-id", - cloudflareAiGatewayApiKey: "cf-gateway-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const { CONFIG_PATH } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { - auth?: { - profiles?: Record; - }; - agents?: { defaults?: { model?: { primary?: string } } }; - }; - - expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe( - "cloudflare-ai-gateway", - ); - expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key"); - expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5"); - - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); - const store = ensureAuthProfileStore(); - const profile = store.profiles["cloudflare-ai-gateway:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.provider).toBe("cloudflare-ai-gateway"); - expect(profile.key).toBe("cf-gateway-test-key"); - expect(profile.metadata).toEqual({ - accountId: "cf-account-id", - gatewayId: "cf-gateway-id", - }); - } - } finally { - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.OPENCLAW_STATE_DIR = prev.stateDir; - process.env.OPENCLAW_CONFIG_PATH = prev.configPath; - process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.OPENCLAW_SKIP_CRON = prev.skipCron; - process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; - process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); - - it("infers auth choice from API key flags", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-cf-gateway-infer-")); - process.env.HOME = tempHome; - process.env.OPENCLAW_STATE_DIR = tempHome; - process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); - vi.resetModules(); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - cloudflareAiGatewayAccountId: "cf-account-id", - cloudflareAiGatewayGatewayId: "cf-gateway-id", - cloudflareAiGatewayApiKey: "cf-gateway-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const { CONFIG_PATH } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { - auth?: { - profiles?: Record; - }; - agents?: { defaults?: { model?: { primary?: string } } }; - }; - - expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe( - "cloudflare-ai-gateway", - ); - expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key"); - expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5"); - - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); - const store = ensureAuthProfileStore(); - const profile = store.profiles["cloudflare-ai-gateway:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.provider).toBe("cloudflare-ai-gateway"); - expect(profile.key).toBe("cf-gateway-test-key"); - expect(profile.metadata).toEqual({ - accountId: "cf-account-id", - gatewayId: "cf-gateway-id", - }); - } - } finally { - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.OPENCLAW_STATE_DIR = prev.stateDir; - process.env.OPENCLAW_CONFIG_PATH = prev.configPath; - process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.OPENCLAW_SKIP_CRON = prev.skipCron; - process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; - process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); -}); diff --git a/src/commands/onboard-non-interactive.openai-api-key.test.ts b/src/commands/onboard-non-interactive.openai-api-key.test.ts deleted file mode 100644 index 1a9d5989e9..0000000000 --- a/src/commands/onboard-non-interactive.openai-api-key.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js"; - -describe("onboard (non-interactive): OpenAI API key", () => { - it("stores OPENAI_API_KEY and configures the OpenAI default model", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-openai-")); - process.env.HOME = tempHome; - process.env.OPENCLAW_STATE_DIR = tempHome; - process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); - vi.resetModules(); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - authChoice: "openai-api-key", - openaiApiKey: "sk-openai-test", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const { CONFIG_PATH } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { - agents?: { defaults?: { model?: { primary?: string } } }; - }; - expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL); - } finally { - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.OPENCLAW_STATE_DIR = prev.stateDir; - process.env.OPENCLAW_CONFIG_PATH = prev.configPath; - process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.OPENCLAW_SKIP_CRON = prev.skipCron; - process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; - process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); -}); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts new file mode 100644 index 0000000000..d3edb1891d --- /dev/null +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -0,0 +1,327 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js"; + +type RuntimeMock = { + log: () => void; + error: (msg: string) => never; + exit: (code: number) => never; +}; + +type EnvSnapshot = { + home: string | undefined; + stateDir: string | undefined; + configPath: string | undefined; + skipChannels: string | undefined; + skipGmail: string | undefined; + skipCron: string | undefined; + skipCanvas: string | undefined; + token: string | undefined; + password: string | undefined; + disableConfigCache: string | undefined; +}; + +type OnboardEnv = { + configPath: string; + runtime: RuntimeMock; +}; + +function captureEnv(): EnvSnapshot { + return { + home: process.env.HOME, + stateDir: process.env.OPENCLAW_STATE_DIR, + configPath: process.env.OPENCLAW_CONFIG_PATH, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + password: process.env.OPENCLAW_GATEWAY_PASSWORD, + disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE, + }; +} + +function restoreEnvVar(key: keyof NodeJS.ProcessEnv, value: string | undefined): void { + if (value == null) { + delete process.env[key]; + return; + } + process.env[key] = value; +} + +function restoreEnv(prev: EnvSnapshot): void { + restoreEnvVar("HOME", prev.home); + restoreEnvVar("OPENCLAW_STATE_DIR", prev.stateDir); + restoreEnvVar("OPENCLAW_CONFIG_PATH", prev.configPath); + restoreEnvVar("OPENCLAW_SKIP_CHANNELS", prev.skipChannels); + restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", prev.skipGmail); + restoreEnvVar("OPENCLAW_SKIP_CRON", prev.skipCron); + restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas); + restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token); + restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password); + restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache); +} + +async function withOnboardEnv( + prefix: string, + run: (ctx: OnboardEnv) => Promise, +): Promise { + const prev = captureEnv(); + + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1"; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const configPath = path.join(tempHome, "openclaw.json"); + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = tempHome; + process.env.OPENCLAW_CONFIG_PATH = configPath; + vi.resetModules(); + + const runtime: RuntimeMock = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + try { + await run({ configPath, runtime }); + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + restoreEnv(prev); + } +} + +async function runNonInteractive( + options: Record, + runtime: RuntimeMock, +): Promise { + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); + await runNonInteractiveOnboarding(options, runtime); +} + +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as T; +} + +async function expectApiKeyProfile(params: { + profileId: string; + provider: string; + key: string; + metadata?: Record; +}): Promise { + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); + const store = ensureAuthProfileStore(); + const profile = store.profiles[params.profileId]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.provider).toBe(params.provider); + expect(profile.key).toBe(params.key); + if (params.metadata) { + expect(profile.metadata).toEqual(params.metadata); + } + } +} + +describe("onboard (non-interactive): provider auth", () => { + it("stores xAI API key and sets default model", async () => { + await withOnboardEnv("openclaw-onboard-xai-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + authChoice: "xai-api-key", + xaiApiKey: "xai-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + auth?: { profiles?: Record }; + agents?: { defaults?: { model?: { primary?: string } } }; + }>(configPath); + + expect(cfg.auth?.profiles?.["xai:default"]?.provider).toBe("xai"); + expect(cfg.auth?.profiles?.["xai:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe("xai/grok-4"); + await expectApiKeyProfile({ profileId: "xai:default", provider: "xai", key: "xai-test-key" }); + }); + }, 60_000); + + it("stores Vercel AI Gateway API key and sets default model", async () => { + await withOnboardEnv("openclaw-onboard-ai-gateway-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + authChoice: "ai-gateway-api-key", + aiGatewayApiKey: "gateway-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + auth?: { profiles?: Record }; + agents?: { defaults?: { model?: { primary?: string } } }; + }>(configPath); + + expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.provider).toBe("vercel-ai-gateway"); + expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe( + "vercel-ai-gateway/anthropic/claude-opus-4.6", + ); + await expectApiKeyProfile({ + profileId: "vercel-ai-gateway:default", + provider: "vercel-ai-gateway", + key: "gateway-test-key", + }); + }); + }, 60_000); + + it("stores token auth profile", async () => { + await withOnboardEnv("openclaw-onboard-token-", async ({ configPath, runtime }) => { + const token = `sk-ant-oat01-${"a".repeat(80)}`; + + await runNonInteractive( + { + nonInteractive: true, + authChoice: "token", + tokenProvider: "anthropic", + token, + tokenProfileId: "anthropic:default", + skipHealth: true, + skipChannels: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + auth?: { profiles?: Record }; + }>(configPath); + + expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic"); + expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token"); + + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); + const store = ensureAuthProfileStore(); + const profile = store.profiles["anthropic:default"]; + expect(profile?.type).toBe("token"); + if (profile?.type === "token") { + expect(profile.provider).toBe("anthropic"); + expect(profile.token).toBe(token); + } + }); + }, 60_000); + + it("stores OpenAI API key and sets OpenAI default model", async () => { + await withOnboardEnv("openclaw-onboard-openai-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + authChoice: "openai-api-key", + openaiApiKey: "sk-openai-test", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + agents?: { defaults?: { model?: { primary?: string } } }; + }>(configPath); + + expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL); + }); + }, 60_000); + + it("stores Cloudflare AI Gateway API key and metadata", async () => { + await withOnboardEnv("openclaw-onboard-cf-gateway-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + authChoice: "cloudflare-ai-gateway-api-key", + cloudflareAiGatewayAccountId: "cf-account-id", + cloudflareAiGatewayGatewayId: "cf-gateway-id", + cloudflareAiGatewayApiKey: "cf-gateway-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + auth?: { profiles?: Record }; + agents?: { defaults?: { model?: { primary?: string } } }; + }>(configPath); + + expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe( + "cloudflare-ai-gateway", + ); + expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5"); + await expectApiKeyProfile({ + profileId: "cloudflare-ai-gateway:default", + provider: "cloudflare-ai-gateway", + key: "cf-gateway-test-key", + metadata: { accountId: "cf-account-id", gatewayId: "cf-gateway-id" }, + }); + }); + }, 60_000); + + it("infers Cloudflare auth choice from API key flags", async () => { + await withOnboardEnv("openclaw-onboard-cf-gateway-infer-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + cloudflareAiGatewayAccountId: "cf-account-id", + cloudflareAiGatewayGatewayId: "cf-gateway-id", + cloudflareAiGatewayApiKey: "cf-gateway-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + auth?: { profiles?: Record }; + agents?: { defaults?: { model?: { primary?: string } } }; + }>(configPath); + + expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe( + "cloudflare-ai-gateway", + ); + expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5"); + await expectApiKeyProfile({ + profileId: "cloudflare-ai-gateway:default", + provider: "cloudflare-ai-gateway", + key: "cf-gateway-test-key", + metadata: { accountId: "cf-account-id", gatewayId: "cf-gateway-id" }, + }); + }); + }, 60_000); +}); diff --git a/src/commands/onboard-non-interactive.token.test.ts b/src/commands/onboard-non-interactive.token.test.ts deleted file mode 100644 index bcbe2c221b..0000000000 --- a/src/commands/onboard-non-interactive.token.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -describe("onboard (non-interactive): token auth", () => { - it("writes token profile config and stores the token", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-token-")); - process.env.HOME = tempHome; - process.env.OPENCLAW_STATE_DIR = tempHome; - process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); - vi.resetModules(); - - const token = `sk-ant-oat01-${"a".repeat(80)}`; - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - authChoice: "token", - tokenProvider: "anthropic", - token, - tokenProfileId: "anthropic:default", - skipHealth: true, - skipChannels: true, - json: true, - }, - runtime, - ); - - const { CONFIG_PATH } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { - auth?: { - profiles?: Record; - }; - }; - - expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic"); - expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token"); - - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); - const store = ensureAuthProfileStore(); - const profile = store.profiles["anthropic:default"]; - expect(profile?.type).toBe("token"); - if (profile?.type === "token") { - expect(profile.provider).toBe("anthropic"); - expect(profile.token).toBe(token); - } - } finally { - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.OPENCLAW_STATE_DIR = prev.stateDir; - process.env.OPENCLAW_CONFIG_PATH = prev.configPath; - process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.OPENCLAW_SKIP_CRON = prev.skipCron; - process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; - process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); -}); diff --git a/src/commands/onboard-non-interactive.xai.test.ts b/src/commands/onboard-non-interactive.xai.test.ts deleted file mode 100644 index 1c4d2dda7f..0000000000 --- a/src/commands/onboard-non-interactive.xai.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -describe("onboard (non-interactive): xAI", () => { - it("stores the API key and configures the default model", async () => { - const prev = { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-xai-")); - process.env.HOME = tempHome; - process.env.OPENCLAW_STATE_DIR = tempHome; - process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); - vi.resetModules(); - - const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; - - try { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding( - { - nonInteractive: true, - authChoice: "xai-api-key", - xaiApiKey: "xai-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); - - const { CONFIG_PATH } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { - auth?: { - profiles?: Record; - }; - agents?: { defaults?: { model?: { primary?: string } } }; - }; - - expect(cfg.auth?.profiles?.["xai:default"]?.provider).toBe("xai"); - expect(cfg.auth?.profiles?.["xai:default"]?.mode).toBe("api_key"); - expect(cfg.agents?.defaults?.model?.primary).toBe("xai/grok-4"); - - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); - const store = ensureAuthProfileStore(); - const profile = store.profiles["xai:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.provider).toBe("xai"); - expect(profile.key).toBe("xai-test-key"); - } - } finally { - await fs.rm(tempHome, { recursive: true, force: true }); - process.env.HOME = prev.home; - process.env.OPENCLAW_STATE_DIR = prev.stateDir; - process.env.OPENCLAW_CONFIG_PATH = prev.configPath; - process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; - process.env.OPENCLAW_SKIP_CRON = prev.skipCron; - process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; - process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; - process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; - } - }, 60_000); -}); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 0475aec502..54fb5bd174 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -25,9 +25,15 @@ function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number } }; if (params?.autoClose !== false) { const delayMs = params?.closeDelayMs ?? 0; - setTimeout(() => { - child.emit("close", 0); - }, delayMs); + if (delayMs <= 0) { + queueMicrotask(() => { + child.emit("close", 0); + }); + } else { + setTimeout(() => { + child.emit("close", 0); + }, delayMs); + } } return child; } @@ -73,6 +79,7 @@ describe("QmdMemoryManager", () => { }); afterEach(async () => { + vi.useRealTimers(); delete process.env.OPENCLAW_STATE_DIR; await fs.rm(tmpRoot, { recursive: true, force: true }); }); @@ -218,6 +225,7 @@ describe("QmdMemoryManager", () => { }); it("times out qmd update during sync when configured", async () => { + vi.useFakeTimers(); cfg = { ...cfg, memory: { @@ -242,14 +250,17 @@ describe("QmdMemoryManager", () => { }); const resolved = resolveMemoryBackendConfig({ cfg, agentId }); - const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved }); + await vi.advanceTimersByTimeAsync(0); + const manager = await createPromise; expect(manager).toBeTruthy(); if (!manager) { throw new Error("manager missing"); } - await expect(manager.sync({ reason: "manual" })).rejects.toThrow( - "qmd update timed out after 20ms", - ); + const syncPromise = manager.sync({ reason: "manual" }); + const rejected = expect(syncPromise).rejects.toThrow("qmd update timed out after 20ms"); + await vi.advanceTimersByTimeAsync(20); + await rejected; await manager.close(); }); @@ -378,6 +389,7 @@ describe("QmdMemoryManager", () => { }); it("logs and continues when qmd embed times out", async () => { + vi.useFakeTimers(); cfg = { ...cfg, memory: { @@ -402,12 +414,17 @@ describe("QmdMemoryManager", () => { }); const resolved = resolveMemoryBackendConfig({ cfg, agentId }); - const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved }); + await vi.advanceTimersByTimeAsync(0); + const manager = await createPromise; expect(manager).toBeTruthy(); if (!manager) { throw new Error("manager missing"); } - await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined(); + const syncPromise = manager.sync({ reason: "manual" }); + const resolvedSync = expect(syncPromise).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(20); + await resolvedSync; await manager.close(); }); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 27b55e4823..7fc5b8843c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -11,6 +11,20 @@ import { runSecurityAudit } from "./audit.js"; const isWindows = process.platform === "win32"; +function successfulProbeResult(url: string) { + return { + ok: true, + url, + connectLatencyMs: 1, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; +} + describe("security audit", () => { it("includes an attack surface summary (info)", async () => { const cfg: OpenClawConfig = { @@ -1021,6 +1035,7 @@ describe("security audit", () => { includeChannelSecurity: false, deep: true, stateDir: tmpDir, + probeGatewayFn: async (opts) => successfulProbeResult(opts.url), }); expect( @@ -1075,6 +1090,7 @@ description: test skill includeChannelSecurity: false, deep: true, stateDir: tmpDir, + probeGatewayFn: async (opts) => successfulProbeResult(opts.url), }); const pluginFinding = deepRes.findings.find( @@ -1113,6 +1129,7 @@ description: test skill includeChannelSecurity: false, deep: true, stateDir: tmpDir, + probeGatewayFn: async (opts) => successfulProbeResult(opts.url), }); expect(res.findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); diff --git a/src/web/login.test.ts b/src/web/login.test.ts index c3ed32e521..5ef6f889bd 100644 --- a/src/web/login.test.ts +++ b/src/web/login.test.ts @@ -23,10 +23,12 @@ const { createWaSocket } = await import("./session.js"); describe("web login", () => { beforeEach(() => { + vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { + vi.useRealTimers(); resetLogger(); setLoggerOverride(null); }); @@ -36,7 +38,12 @@ describe("web login", () => { const close = vi.spyOn(sock.ws, "close"); const waiter: typeof waitForWaConnection = vi.fn().mockResolvedValue(undefined); await loginWeb(false, waiter); - await new Promise((resolve) => setTimeout(resolve, 550)); - expect(close).toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(499); + expect(close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(close).toHaveBeenCalledTimes(1); }); });