refactor(test): temp home env + normalize status

This commit is contained in:
Peter Steinberger
2026-01-09 16:49:01 +01:00
parent e8d75a39bc
commit 8341b662af
7 changed files with 190 additions and 132 deletions

View File

@@ -30,22 +30,15 @@ vi.mock("../agents/model-catalog.js", () => ({
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase( return withTempHomeBase(
async (home) => { async (home) => {
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; return await fn(home);
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; env: {
try { CLAWDBOT_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
return await fn(home); PI_CODING_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
} finally { },
if (previousAgentDir === undefined) prefix: "clawdbot-reply-",
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;
}
}, },
{ prefix: "clawdbot-reply-" },
); );
} }

View File

@@ -29,23 +29,16 @@ function makeResult(text: string) {
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase( return withTempHomeBase(
async (home) => { async (home) => {
const previousBundledSkills = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; vi.mocked(runEmbeddedPiAgent).mockReset();
process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = path.join( return await fn(home);
home, },
"bundled-skills", {
); env: {
try { CLAWDBOT_BUNDLED_SKILLS_DIR: (home) =>
vi.mocked(runEmbeddedPiAgent).mockReset(); path.join(home, "bundled-skills"),
return await fn(home); },
} finally { prefix: "clawdbot-media-note-",
if (previousBundledSkills === undefined) {
delete process.env.CLAWDBOT_BUNDLED_SKILLS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = previousBundledSkills;
}
}
}, },
{ prefix: "clawdbot-media-note-" },
); );
} }

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { basename, join } from "node:path"; import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; 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"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../agents/pi-embedded.js", () => ({
@@ -100,7 +100,7 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; 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(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["anthropic"] }), expect.objectContaining({ providers: ["anthropic"] }),
); );

View File

@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { buildStatusMessage } from "./status.js"; import { buildStatusMessage } from "./status.js";
@@ -55,19 +56,22 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
now: 10 * 60_000, // 10 minutes later now: 10 * 60_000, // 10 minutes later
}); });
const normalized = normalizeTestText(text);
expect(text).toContain("🦞 ClawdBot"); expect(normalized).toContain("ClawdBot");
expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key"); expect(normalized).toContain("Model: anthropic/pi:opus");
expect(text).toContain("🧮 Tokens: 1.2k in / 800 out · 💵 Cost: $0.0020"); expect(normalized).toContain("api-key");
expect(text).toContain("Context: 16k/32k (50%)"); expect(normalized).toContain("Tokens: 1.2k in / 800 out");
expect(text).toContain("🧹 Compactions: 2"); expect(normalized).toContain("Cost: $0.0020");
expect(text).toContain("Session: agent:main:main"); expect(normalized).toContain("Context: 16k/32k (50%)");
expect(text).toContain("updated 10m ago"); expect(normalized).toContain("Compactions: 2");
expect(text).toContain("Runtime: direct"); expect(normalized).toContain("Session: agent:main:main");
expect(text).toContain("Think: medium"); expect(normalized).toContain("updated 10m ago");
expect(text).toContain("Verbose: off"); expect(normalized).toContain("Runtime: direct");
expect(text).toContain("Elevated: on"); expect(normalized).toContain("Think: medium");
expect(text).toContain("Queue: collect"); expect(normalized).toContain("Verbose: off");
expect(normalized).toContain("Elevated: on");
expect(normalized).toContain("Queue: collect");
}); });
it("shows verbose/elevated labels only when enabled", () => { it("shows verbose/elevated labels only when enabled", () => {
@@ -107,7 +111,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", 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", () => { it("keeps provider prefix from configured model", () => {
@@ -120,7 +124,9 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", 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", () => { it("handles missing agent config gracefully", () => {
@@ -131,9 +137,10 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("🧠 Model:"); const normalized = normalizeTestText(text);
expect(text).toContain("Context:"); expect(normalized).toContain("Model:");
expect(text).toContain("Queue: collect"); expect(normalized).toContain("Context:");
expect(normalized).toContain("Queue: collect");
}); });
it("includes group activation for group sessions", () => { it("includes group activation for group sessions", () => {
@@ -187,10 +194,10 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
const lines = text.split("\n"); const lines = normalizeTestText(text).split("\n");
const contextIndex = lines.findIndex((line) => line.startsWith("📚 ")); const contextIndex = lines.findIndex((line) => line.includes("Context:"));
expect(contextIndex).toBeGreaterThan(-1); 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", () => { it("hides cost when not using an API key", () => {
@@ -283,7 +290,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("Context: 1.0k/32k"); expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
}, },
{ prefix: "clawdbot-status-" }, { prefix: "clawdbot-status-" },
); );

View File

@@ -138,95 +138,92 @@ describe("provider usage loading", () => {
it("discovers Claude usage from token auth profiles", async () => { it("discovers Claude usage from token auth profiles", async () => {
await withTempHome( await withTempHome(
async (tempHome) => { async (tempHome) => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
const agentDir = path.join( const agentDir = path.join(
process.env.CLAWDBOT_STATE_DIR, process.env.CLAWDBOT_STATE_DIR ?? path.join(tempHome, ".clawdbot"),
"agents", "agents",
"main", "main",
"agent", "agent",
); );
try { fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); fs.writeFileSync(
fs.writeFileSync( path.join(agentDir, "auth-profiles.json"),
path.join(agentDir, "auth-profiles.json"), `${JSON.stringify(
`${JSON.stringify( {
{ version: 1,
version: 1, order: { anthropic: ["anthropic:default"] },
order: { anthropic: ["anthropic:default"] }, profiles: {
profiles: { "anthropic:default": {
"anthropic:default": { type: "token",
type: "token", provider: "anthropic",
provider: "anthropic", token: "token-1",
token: "token-1", expires: Date.UTC(2100, 0, 1, 0, 0, 0),
expires: Date.UTC(2100, 0, 1, 0, 0, 0),
},
}, },
}, },
null, },
2, null,
)}\n`, 2,
"utf8", )}\n`,
); "utf8",
const store = ensureAuthProfileStore(agentDir, { );
allowKeychainPrompt: false, const store = ensureAuthProfileStore(agentDir, {
}); allowKeychainPrompt: false,
expect(listProfilesForProvider(store, "anthropic")).toContain( });
"anthropic:default", expect(listProfilesForProvider(store, "anthropic")).toContain(
); "anthropic:default",
);
const makeResponse = (status: number, body: unknown): Response => { const makeResponse = (status: number, body: unknown): Response => {
const payload = const payload =
typeof body === "string" ? body : JSON.stringify(body); typeof body === "string" ? body : JSON.stringify(body);
const headers = const headers =
typeof body === "string" typeof body === "string"
? undefined ? undefined
: { "Content-Type": "application/json" }; : { "Content-Type": "application/json" };
return new Response(payload, { status, headers }); return new Response(payload, { status, headers });
}; };
const mockFetch = vi.fn< const mockFetch = vi.fn<
Parameters<typeof fetch>, Parameters<typeof fetch>,
ReturnType<typeof fetch> ReturnType<typeof fetch>
>(async (input, init) => { >(async (input, init) => {
const url = const url =
typeof input === "string" typeof input === "string"
? input ? input
: input instanceof URL : input instanceof URL
? input.toString() ? input.toString()
: input.url; : input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) { if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>; const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-1"); expect(headers.Authorization).toBe("Bearer token-1");
return makeResponse(200, { return makeResponse(200, {
five_hour: { five_hour: {
utilization: 20, utilization: 20,
resets_at: "2026-01-07T01:00:00Z", resets_at: "2026-01-07T01:00:00Z",
}, },
}); });
} }
return makeResponse(404, "not found"); return makeResponse(404, "not found");
}); });
const summary = await loadProviderUsageSummary({ const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0), now: Date.UTC(2026, 0, 7, 0, 0, 0),
providers: ["anthropic"], providers: ["anthropic"],
agentDir, agentDir,
fetch: mockFetch, fetch: mockFetch,
}); });
expect(summary.providers).toHaveLength(1); expect(summary.providers).toHaveLength(1);
const claude = summary.providers[0]; const claude = summary.providers[0];
expect(claude?.provider).toBe("anthropic"); expect(claude?.provider).toBe("anthropic");
expect(claude?.windows[0]?.label).toBe("5h"); expect(claude?.windows[0]?.label).toBe("5h");
expect(mockFetch).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalled();
} finally { },
if (previousStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR; env: {
else process.env.CLAWDBOT_STATE_DIR = previousStateDir; CLAWDBOT_STATE_DIR: (home) => path.join(home, ".clawdbot"),
} },
prefix: "clawdbot-provider-usage-",
}, },
{ prefix: "clawdbot-provider-usage-" },
); );
}); });

View File

@@ -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, "?");
}

View File

@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
type EnvValue = string | undefined | ((home: string) => string | undefined);
type EnvSnapshot = { type EnvSnapshot = {
home: string | undefined; home: string | undefined;
userProfile: string | undefined; userProfile: string | undefined;
@@ -35,6 +37,19 @@ function restoreEnv(snapshot: EnvSnapshot) {
restoreKey("CLAWDIS_STATE_DIR", snapshot.legacyStateDir); restoreKey("CLAWDIS_STATE_DIR", snapshot.legacyStateDir);
} }
function snapshotExtraEnv(keys: string[]): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of keys) snapshot[key] = process.env[key];
return snapshot;
}
function restoreExtraEnv(snapshot: Record<string, string | undefined>) {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
function setTempHome(base: string) { function setTempHome(base: string) {
process.env.HOME = base; process.env.HOME = base;
process.env.USERPROFILE = base; process.env.USERPROFILE = base;
@@ -50,17 +65,38 @@ function setTempHome(base: string) {
export async function withTempHome<T>( export async function withTempHome<T>(
fn: (home: string) => Promise<T>, fn: (home: string) => Promise<T>,
opts: { prefix?: string } = {}, opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
): Promise<T> { ): Promise<T> {
const base = await fs.mkdtemp( const base = await fs.mkdtemp(
path.join(os.tmpdir(), opts.prefix ?? "clawdbot-test-home-"), path.join(os.tmpdir(), opts.prefix ?? "clawdbot-test-home-"),
); );
const snapshot = snapshotEnv(); 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); 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 { try {
return await fn(base); return await fn(base);
} finally { } finally {
restoreExtraEnv(envSnapshot);
restoreEnv(snapshot); restoreEnv(snapshot);
try { try {
await fs.rm(base, { await fs.rm(base, {