refactor(test): temp home env + normalize status
This commit is contained in:
@@ -30,22 +30,15 @@ vi.mock("../agents/model-catalog.js", () => ({
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
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-" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,23 +29,16 @@ function makeResult(text: string) {
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
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-" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }),
|
||||
);
|
||||
|
||||
@@ -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-" },
|
||||
);
|
||||
|
||||
@@ -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<typeof fetch>,
|
||||
ReturnType<typeof fetch>
|
||||
>(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<string, string>;
|
||||
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<typeof fetch>,
|
||||
ReturnType<typeof fetch>
|
||||
>(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<string, string>;
|
||||
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-" },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
32
test/helpers/normalize-text.ts
Normal file
32
test/helpers/normalize-text.ts
Normal 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, "?");
|
||||
}
|
||||
@@ -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<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) {
|
||||
process.env.HOME = base;
|
||||
process.env.USERPROFILE = base;
|
||||
@@ -50,17 +65,38 @@ function setTempHome(base: string) {
|
||||
|
||||
export async function withTempHome<T>(
|
||||
fn: (home: string) => Promise<T>,
|
||||
opts: { prefix?: string } = {},
|
||||
opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
|
||||
): Promise<T> {
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user