diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index bd93e17dc..b494c1057 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -30,22 +30,15 @@ vi.mock("../agents/model-catalog.js", () => ({ async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase( async (home) => { - const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - process.env.CLAWDBOT_AGENT_DIR = path.join(home, ".clawdbot", "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; - try { - return await fn(home); - } finally { - if (previousAgentDir === undefined) - delete process.env.CLAWDBOT_AGENT_DIR; - else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; - if (previousPiAgentDir === undefined) - delete process.env.PI_CODING_AGENT_DIR; - else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + return await fn(home); + }, + { + env: { + CLAWDBOT_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"), + PI_CODING_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"), + }, + prefix: "clawdbot-reply-", }, - { prefix: "clawdbot-reply-" }, ); } diff --git a/src/auto-reply/reply.media-note.test.ts b/src/auto-reply/reply.media-note.test.ts index 623e1ea0f..86bfe03d0 100644 --- a/src/auto-reply/reply.media-note.test.ts +++ b/src/auto-reply/reply.media-note.test.ts @@ -29,23 +29,16 @@ function makeResult(text: string) { async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase( async (home) => { - const previousBundledSkills = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; - process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = path.join( - home, - "bundled-skills", - ); - try { - vi.mocked(runEmbeddedPiAgent).mockReset(); - return await fn(home); - } finally { - if (previousBundledSkills === undefined) { - delete process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; - } else { - process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = previousBundledSkills; - } - } + vi.mocked(runEmbeddedPiAgent).mockReset(); + return await fn(home); + }, + { + env: { + CLAWDBOT_BUNDLED_SKILLS_DIR: (home) => + path.join(home, "bundled-skills"), + }, + prefix: "clawdbot-media-note-", }, - { prefix: "clawdbot-media-note-" }, ); } diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 48308adc3..38f60125d 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; - +import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; vi.mock("../agents/pi-embedded.js", () => ({ @@ -100,7 +100,7 @@ describe("trigger handling", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("๐Ÿ“Š Usage: Claude 80% left"); + expect(normalizeTestText(text ?? "")).toContain("Usage: Claude 80% left"); expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith( expect.objectContaining({ providers: ["anthropic"] }), ); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 06b0c6959..bbd272f2c 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; import { buildStatusMessage } from "./status.js"; @@ -55,19 +56,22 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", now: 10 * 60_000, // 10 minutes later }); + const normalized = normalizeTestText(text); - expect(text).toContain("๐Ÿฆž ClawdBot"); - expect(text).toContain("๐Ÿง  Model: anthropic/pi:opus ยท ๐Ÿ”‘ api-key"); - expect(text).toContain("๐Ÿงฎ Tokens: 1.2k in / 800 out ยท ๐Ÿ’ต Cost: $0.0020"); - expect(text).toContain("Context: 16k/32k (50%)"); - expect(text).toContain("๐Ÿงน Compactions: 2"); - expect(text).toContain("Session: agent:main:main"); - expect(text).toContain("updated 10m ago"); - expect(text).toContain("Runtime: direct"); - expect(text).toContain("Think: medium"); - expect(text).toContain("Verbose: off"); - expect(text).toContain("Elevated: on"); - expect(text).toContain("Queue: collect"); + expect(normalized).toContain("ClawdBot"); + expect(normalized).toContain("Model: anthropic/pi:opus"); + expect(normalized).toContain("api-key"); + expect(normalized).toContain("Tokens: 1.2k in / 800 out"); + expect(normalized).toContain("Cost: $0.0020"); + expect(normalized).toContain("Context: 16k/32k (50%)"); + expect(normalized).toContain("Compactions: 2"); + expect(normalized).toContain("Session: agent:main:main"); + expect(normalized).toContain("updated 10m ago"); + expect(normalized).toContain("Runtime: direct"); + expect(normalized).toContain("Think: medium"); + expect(normalized).toContain("Verbose: off"); + expect(normalized).toContain("Elevated: on"); + expect(normalized).toContain("Queue: collect"); }); it("shows verbose/elevated labels only when enabled", () => { @@ -107,7 +111,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("๐Ÿง  Model: openai/gpt-4.1-mini"); + expect(normalizeTestText(text)).toContain("Model: openai/gpt-4.1-mini"); }); it("keeps provider prefix from configured model", () => { @@ -120,7 +124,9 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("๐Ÿง  Model: google-antigravity/claude-sonnet-4-5"); + expect(normalizeTestText(text)).toContain( + "Model: google-antigravity/claude-sonnet-4-5", + ); }); it("handles missing agent config gracefully", () => { @@ -131,9 +137,10 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("๐Ÿง  Model:"); - expect(text).toContain("Context:"); - expect(text).toContain("Queue: collect"); + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model:"); + expect(normalized).toContain("Context:"); + expect(normalized).toContain("Queue: collect"); }); it("includes group activation for group sessions", () => { @@ -187,10 +194,10 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - const lines = text.split("\n"); - const contextIndex = lines.findIndex((line) => line.startsWith("๐Ÿ“š ")); + const lines = normalizeTestText(text).split("\n"); + const contextIndex = lines.findIndex((line) => line.includes("Context:")); expect(contextIndex).toBeGreaterThan(-1); - expect(lines[contextIndex + 1]).toBe("๐Ÿ“Š Usage: Claude 80% left (5h)"); + expect(lines[contextIndex + 1]).toContain("Usage: Claude 80% left (5h)"); }); it("hides cost when not using an API key", () => { @@ -283,7 +290,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("Context: 1.0k/32k"); + expect(normalizeTestText(text)).toContain("Context: 1.0k/32k"); }, { prefix: "clawdbot-status-" }, ); diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index aa125d6ba..8c0719b84 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -138,95 +138,92 @@ describe("provider usage loading", () => { it("discovers Claude usage from token auth profiles", async () => { await withTempHome( async (tempHome) => { - const previousStateDir = process.env.CLAWDBOT_STATE_DIR; - process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); const agentDir = path.join( - process.env.CLAWDBOT_STATE_DIR, + process.env.CLAWDBOT_STATE_DIR ?? path.join(tempHome, ".clawdbot"), "agents", "main", "agent", ); - try { - fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: 1, - order: { anthropic: ["anthropic:default"] }, - profiles: { - "anthropic:default": { - type: "token", - provider: "anthropic", - token: "token-1", - expires: Date.UTC(2100, 0, 1, 0, 0, 0), - }, + fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + order: { anthropic: ["anthropic:default"] }, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "token-1", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), }, }, - null, - 2, - )}\n`, - "utf8", - ); - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - expect(listProfilesForProvider(store, "anthropic")).toContain( - "anthropic:default", - ); + }, + null, + 2, + )}\n`, + "utf8", + ); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + expect(listProfilesForProvider(store, "anthropic")).toContain( + "anthropic:default", + ); - const makeResponse = (status: number, body: unknown): Response => { - const payload = - typeof body === "string" ? body : JSON.stringify(body); - const headers = - typeof body === "string" - ? undefined - : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); - }; + const makeResponse = (status: number, body: unknown): Response => { + const payload = + typeof body === "string" ? body : JSON.stringify(body); + const headers = + typeof body === "string" + ? undefined + : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; - const mockFetch = vi.fn< - Parameters, - ReturnType - >(async (input, init) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - if (url.includes("api.anthropic.com/api/oauth/usage")) { - const headers = (init?.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Bearer token-1"); - return makeResponse(200, { - five_hour: { - utilization: 20, - resets_at: "2026-01-07T01:00:00Z", - }, - }); - } - return makeResponse(404, "not found"); - }); + const mockFetch = vi.fn< + Parameters, + ReturnType + >(async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes("api.anthropic.com/api/oauth/usage")) { + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe("Bearer token-1"); + return makeResponse(200, { + five_hour: { + utilization: 20, + resets_at: "2026-01-07T01:00:00Z", + }, + }); + } + return makeResponse(404, "not found"); + }); - const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), - providers: ["anthropic"], - agentDir, - fetch: mockFetch, - }); + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + providers: ["anthropic"], + agentDir, + fetch: mockFetch, + }); - expect(summary.providers).toHaveLength(1); - const claude = summary.providers[0]; - expect(claude?.provider).toBe("anthropic"); - expect(claude?.windows[0]?.label).toBe("5h"); - expect(mockFetch).toHaveBeenCalled(); - } finally { - if (previousStateDir === undefined) - delete process.env.CLAWDBOT_STATE_DIR; - else process.env.CLAWDBOT_STATE_DIR = previousStateDir; - } + expect(summary.providers).toHaveLength(1); + const claude = summary.providers[0]; + expect(claude?.provider).toBe("anthropic"); + expect(claude?.windows[0]?.label).toBe("5h"); + expect(mockFetch).toHaveBeenCalled(); + }, + { + env: { + CLAWDBOT_STATE_DIR: (home) => path.join(home, ".clawdbot"), + }, + prefix: "clawdbot-provider-usage-", }, - { prefix: "clawdbot-provider-usage-" }, ); }); diff --git a/test/helpers/normalize-text.ts b/test/helpers/normalize-text.ts new file mode 100644 index 000000000..775345134 --- /dev/null +++ b/test/helpers/normalize-text.ts @@ -0,0 +1,32 @@ +function stripAnsi(input: string): string { + let out = ""; + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i); + if (code !== 27) { + out += input[i]; + continue; + } + + const next = input[i + 1]; + if (next !== "[") continue; + i += 1; + + while (i + 1 < input.length) { + i += 1; + const c = input[i]; + if (!c) break; + const isLetter = + (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "~"; + if (isLetter) break; + } + } + return out; +} + +export function normalizeTestText(input: string): string { + return stripAnsi(input) + .replaceAll("\r\n", "\n") + .replaceAll("โ€ฆ", "...") + .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?") + .replace(/[\uD800-\uDFFF]/g, "?"); +} diff --git a/test/helpers/temp-home.ts b/test/helpers/temp-home.ts index 5c7320a4a..337a71371 100644 --- a/test/helpers/temp-home.ts +++ b/test/helpers/temp-home.ts @@ -2,6 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +type EnvValue = string | undefined | ((home: string) => string | undefined); + type EnvSnapshot = { home: string | undefined; userProfile: string | undefined; @@ -35,6 +37,19 @@ function restoreEnv(snapshot: EnvSnapshot) { restoreKey("CLAWDIS_STATE_DIR", snapshot.legacyStateDir); } +function snapshotExtraEnv(keys: string[]): Record { + const snapshot: Record = {}; + for (const key of keys) snapshot[key] = process.env[key]; + return snapshot; +} + +function restoreExtraEnv(snapshot: Record) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + function setTempHome(base: string) { process.env.HOME = base; process.env.USERPROFILE = base; @@ -50,17 +65,38 @@ function setTempHome(base: string) { export async function withTempHome( fn: (home: string) => Promise, - opts: { prefix?: string } = {}, + opts: { env?: Record; prefix?: string } = {}, ): Promise { const base = await fs.mkdtemp( path.join(os.tmpdir(), opts.prefix ?? "clawdbot-test-home-"), ); const snapshot = snapshotEnv(); + const envKeys = Object.keys(opts.env ?? {}); + for (const key of envKeys) { + if ( + key === "HOME" || + key === "USERPROFILE" || + key === "HOMEDRIVE" || + key === "HOMEPATH" + ) { + throw new Error(`withTempHome: use built-in home env (got ${key})`); + } + } + const envSnapshot = snapshotExtraEnv(envKeys); + setTempHome(base); + if (opts.env) { + for (const [key, raw] of Object.entries(opts.env)) { + const value = typeof raw === "function" ? raw(base) : raw; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } try { return await fn(base); } finally { + restoreExtraEnv(envSnapshot); restoreEnv(snapshot); try { await fs.rm(base, {