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> {
|
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-" },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-" },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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-" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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-" },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user