test: raise vitest coverage
This commit is contained in:
12
src/agents/index.test.ts
Normal file
12
src/agents/index.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getAgentSpec } from "./index.js";
|
||||||
|
|
||||||
|
describe("agents index", () => {
|
||||||
|
it("returns a spec for pi", () => {
|
||||||
|
const spec = getAgentSpec("pi");
|
||||||
|
expect(spec).toBeTruthy();
|
||||||
|
expect(spec.kind).toBe("pi");
|
||||||
|
expect(typeof spec.parseOutput).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/agents/pi-path.test.ts
Normal file
34
src/agents/pi-path.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resolveBundledPiBinary } from "./pi-path.js";
|
||||||
|
|
||||||
|
describe("pi-path", () => {
|
||||||
|
it("resolves to a bundled binary path when available", () => {
|
||||||
|
const resolved = resolveBundledPiBinary();
|
||||||
|
expect(resolved === null || typeof resolved === "string").toBe(true);
|
||||||
|
if (typeof resolved === "string") {
|
||||||
|
expect(resolved).toMatch(/pi-coding-agent/);
|
||||||
|
expect(resolved).toMatch(/dist\/pi|dist\/cli\.js|bin\/tau-dev\.mjs/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers dist/pi when present (branch coverage)", () => {
|
||||||
|
const original = fs.existsSync.bind(fs);
|
||||||
|
const spy = vi.spyOn(fs, "existsSync").mockImplementation((p) => {
|
||||||
|
const s = String(p);
|
||||||
|
if (s.endsWith(path.join("dist", "pi"))) return true;
|
||||||
|
return original(p);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resolved = resolveBundledPiBinary();
|
||||||
|
expect(resolved).not.toBeNull();
|
||||||
|
expect(typeof resolved).toBe("string");
|
||||||
|
expect(resolved).toMatch(/dist\/pi$/);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/auto-reply/tool-meta.test.ts
Normal file
83
src/auto-reply/tool-meta.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createToolDebouncer,
|
||||||
|
formatToolAggregate,
|
||||||
|
formatToolPrefix,
|
||||||
|
shortenMeta,
|
||||||
|
shortenPath,
|
||||||
|
} from "./tool-meta.js";
|
||||||
|
|
||||||
|
describe("tool meta formatting", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shortens paths under HOME", () => {
|
||||||
|
vi.stubEnv("HOME", "/Users/test");
|
||||||
|
expect(shortenPath("/Users/test")).toBe("~");
|
||||||
|
expect(shortenPath("/Users/test/a/b.txt")).toBe("~/a/b.txt");
|
||||||
|
expect(shortenPath("/opt/x")).toBe("/opt/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shortens meta strings with optional colon suffix", () => {
|
||||||
|
vi.stubEnv("HOME", "/Users/test");
|
||||||
|
expect(shortenMeta("/Users/test/a.txt")).toBe("~/a.txt");
|
||||||
|
expect(shortenMeta("/Users/test/a.txt:12")).toBe("~/a.txt:12");
|
||||||
|
expect(shortenMeta("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats aggregates with grouping and brace-collapse", () => {
|
||||||
|
vi.stubEnv("HOME", "/Users/test");
|
||||||
|
const out = formatToolAggregate(" fs ", [
|
||||||
|
"/Users/test/dir/a.txt",
|
||||||
|
"/Users/test/dir/b.txt",
|
||||||
|
"note",
|
||||||
|
"a→b",
|
||||||
|
]);
|
||||||
|
expect(out).toMatch(/^\[🛠️ fs]/);
|
||||||
|
expect(out).toContain("~/dir/{a.txt, b.txt}");
|
||||||
|
expect(out).toContain("note");
|
||||||
|
expect(out).toContain("a→b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats prefixes with default labels", () => {
|
||||||
|
vi.stubEnv("HOME", "/Users/test");
|
||||||
|
expect(formatToolPrefix(undefined, undefined)).toBe("[🛠️ tool]");
|
||||||
|
expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("[🛠️ x ~/a.txt]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool meta debouncer", () => {
|
||||||
|
it("flushes on timer and when tool changes", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const calls: Array<{ tool: string | undefined; metas: string[] }> = [];
|
||||||
|
const d = createToolDebouncer((tool, metas) => {
|
||||||
|
calls.push({ tool, metas });
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
d.push("a", "/tmp/1");
|
||||||
|
d.push("a", "/tmp/2");
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
tool: "a",
|
||||||
|
metas: ["/tmp/1", "/tmp/2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
d.push("a", "x");
|
||||||
|
d.push("b", "y"); // tool change flushes immediately
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(calls[1]).toMatchObject({ tool: "a", metas: ["x"] });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60);
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
expect(calls[2]).toMatchObject({ tool: "b", metas: ["y"] });
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
199
src/browser/chrome.test.ts
Normal file
199
src/browser/chrome.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import fsp from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decorateClawdProfile,
|
||||||
|
findChromeExecutableMac,
|
||||||
|
isChromeReachable,
|
||||||
|
stopClawdChrome,
|
||||||
|
} from "./chrome.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
|
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
async function readJson(filePath: string): Promise<Record<string, unknown>> {
|
||||||
|
const raw = await fsp.readFile(filePath, "utf-8");
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("browser chrome profile decoration", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes expected name + signed ARGB seed to Chrome prefs", async () => {
|
||||||
|
const userDataDir = await fsp.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-chrome-test-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
|
||||||
|
|
||||||
|
const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0;
|
||||||
|
|
||||||
|
const localState = await readJson(path.join(userDataDir, "Local State"));
|
||||||
|
const profile = localState.profile as Record<string, unknown>;
|
||||||
|
const infoCache = profile.info_cache as Record<string, unknown>;
|
||||||
|
const def = infoCache.Default as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(def.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME);
|
||||||
|
expect(def.shortcut_name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME);
|
||||||
|
expect(def.profile_color_seed).toBe(expectedSignedArgb);
|
||||||
|
expect(def.profile_highlight_color).toBe(expectedSignedArgb);
|
||||||
|
expect(def.default_avatar_fill_color).toBe(expectedSignedArgb);
|
||||||
|
expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb);
|
||||||
|
|
||||||
|
const prefs = await readJson(
|
||||||
|
path.join(userDataDir, "Default", "Preferences"),
|
||||||
|
);
|
||||||
|
const browser = prefs.browser as Record<string, unknown>;
|
||||||
|
const theme = browser.theme as Record<string, unknown>;
|
||||||
|
const autogenerated = prefs.autogenerated as Record<string, unknown>;
|
||||||
|
const autogeneratedTheme = autogenerated.theme as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(theme.user_color2).toBe(expectedSignedArgb);
|
||||||
|
expect(autogeneratedTheme.color).toBe(expectedSignedArgb);
|
||||||
|
|
||||||
|
const marker = await fsp.readFile(
|
||||||
|
path.join(userDataDir, ".clawd-profile-decorated"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
expect(marker.trim()).toMatch(/^\d+$/);
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("best-effort writes name when color is invalid", async () => {
|
||||||
|
const userDataDir = await fsp.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-chrome-test-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
decorateClawdProfile(userDataDir, { color: "lobster-orange" });
|
||||||
|
const localState = await readJson(path.join(userDataDir, "Local State"));
|
||||||
|
const profile = localState.profile as Record<string, unknown>;
|
||||||
|
const infoCache = profile.info_cache as Record<string, unknown>;
|
||||||
|
const def = infoCache.Default as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(def.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME);
|
||||||
|
expect(def.profile_color_seed).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers from missing/invalid preference files", async () => {
|
||||||
|
const userDataDir = await fsp.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-chrome-test-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true });
|
||||||
|
await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON
|
||||||
|
await fsp.writeFile(
|
||||||
|
path.join(userDataDir, "Default", "Preferences"),
|
||||||
|
"[]", // valid JSON but wrong shape
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
|
||||||
|
|
||||||
|
const localState = await readJson(path.join(userDataDir, "Local State"));
|
||||||
|
expect(typeof localState.profile).toBe("object");
|
||||||
|
|
||||||
|
const prefs = await readJson(
|
||||||
|
path.join(userDataDir, "Default", "Preferences"),
|
||||||
|
);
|
||||||
|
expect(typeof prefs.profile).toBe("object");
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when rerun on an existing profile", async () => {
|
||||||
|
const userDataDir = await fsp.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-chrome-test-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
|
||||||
|
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
|
||||||
|
|
||||||
|
const prefs = await readJson(
|
||||||
|
path.join(userDataDir, "Default", "Preferences"),
|
||||||
|
);
|
||||||
|
const profile = prefs.profile as Record<string, unknown>;
|
||||||
|
expect(profile.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME);
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browser chrome helpers", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the first existing Chrome candidate on macOS", () => {
|
||||||
|
const exists = vi
|
||||||
|
.spyOn(fs, "existsSync")
|
||||||
|
.mockImplementation((p) => String(p).includes("Google Chrome Canary"));
|
||||||
|
const exe = findChromeExecutableMac();
|
||||||
|
expect(exe?.kind).toBe("canary");
|
||||||
|
expect(exe?.path).toMatch(/Google Chrome Canary/);
|
||||||
|
exists.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no Chrome candidate exists", () => {
|
||||||
|
const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||||
|
expect(findChromeExecutableMac()).toBeNull();
|
||||||
|
exists.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports reachability based on /json/version", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: true } as unknown as Response),
|
||||||
|
);
|
||||||
|
await expect(isChromeReachable(12345, 50)).resolves.toBe(true);
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: false } as unknown as Response),
|
||||||
|
);
|
||||||
|
await expect(isChromeReachable(12345, 50)).resolves.toBe(false);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
||||||
|
await expect(isChromeReachable(12345, 50)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopClawdChrome no-ops when process is already killed", async () => {
|
||||||
|
const proc = { killed: true, exitCode: null, kill: vi.fn() };
|
||||||
|
await stopClawdChrome(
|
||||||
|
{
|
||||||
|
proc,
|
||||||
|
cdpPort: 12345,
|
||||||
|
} as unknown as Parameters<typeof stopClawdChrome>[0],
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
expect(proc.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopClawdChrome sends SIGTERM and returns once CDP is down", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down")));
|
||||||
|
const proc = { killed: false, exitCode: null, kill: vi.fn() };
|
||||||
|
await stopClawdChrome(
|
||||||
|
{
|
||||||
|
proc,
|
||||||
|
cdpPort: 12345,
|
||||||
|
} as unknown as Parameters<typeof stopClawdChrome>[0],
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { browserStatus } from "./client.js";
|
import {
|
||||||
|
browserClickRef,
|
||||||
|
browserDom,
|
||||||
|
browserEval,
|
||||||
|
browserOpenTab,
|
||||||
|
browserQuery,
|
||||||
|
browserScreenshot,
|
||||||
|
browserSnapshot,
|
||||||
|
browserStatus,
|
||||||
|
browserTabs,
|
||||||
|
} from "./client.js";
|
||||||
|
|
||||||
describe("browser client", () => {
|
describe("browser client", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -21,4 +31,157 @@ describe("browser client", () => {
|
|||||||
/Start .*gateway/i,
|
/Start .*gateway/i,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds useful timeout messaging for abort-like failures", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted")));
|
||||||
|
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(
|
||||||
|
/timed out/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces non-2xx responses with body text", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
text: async () => "conflict",
|
||||||
|
} as unknown as Response),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
browserEval("http://127.0.0.1:18791", { js: "1+1" }),
|
||||||
|
).rejects.toThrow(/409: conflict/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the expected endpoints + methods for common calls", async () => {
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
if (url.endsWith("/tabs") && (!init || init.method === undefined)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
running: true,
|
||||||
|
tabs: [{ targetId: "t1", title: "T", url: "https://x" }],
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.endsWith("/tabs/open")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
targetId: "t2",
|
||||||
|
title: "N",
|
||||||
|
url: "https://y",
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.includes("/screenshot")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
path: "/tmp/a.png",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://x",
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.includes("/query?")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://x",
|
||||||
|
matches: [{ index: 0, tag: "a" }],
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.includes("/dom?")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://x",
|
||||||
|
format: "text",
|
||||||
|
text: "hi",
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.includes("/snapshot?")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
format: "aria",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://x",
|
||||||
|
nodes: [],
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.endsWith("/click")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true, targetId: "t1", url: "https://x" }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
running: true,
|
||||||
|
pid: 1,
|
||||||
|
cdpPort: 18792,
|
||||||
|
chosenBrowser: "chrome",
|
||||||
|
userDataDir: "/tmp",
|
||||||
|
color: "#FF4500",
|
||||||
|
headless: false,
|
||||||
|
attachOnly: false,
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
browserStatus("http://127.0.0.1:18791"),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
running: true,
|
||||||
|
cdpPort: 18792,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
browserOpenTab("http://127.0.0.1:18791", "https://example.com"),
|
||||||
|
).resolves.toMatchObject({ targetId: "t2" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
browserScreenshot("http://127.0.0.1:18791", { fullPage: true }),
|
||||||
|
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });
|
||||||
|
await expect(
|
||||||
|
browserQuery("http://127.0.0.1:18791", { selector: "a", limit: 1 }),
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
await expect(
|
||||||
|
browserDom("http://127.0.0.1:18791", { format: "text", maxChars: 10 }),
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
await expect(
|
||||||
|
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
||||||
|
).resolves.toMatchObject({ ok: true, format: "aria" });
|
||||||
|
await expect(
|
||||||
|
browserClickRef("http://127.0.0.1:18791", { ref: "1" }),
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true);
|
||||||
|
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
|
||||||
|
expect(open?.init?.method).toBe("POST");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
431
src/browser/server.test.ts
Normal file
431
src/browser/server.test.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { type AddressInfo, createServer } from "node:net";
|
||||||
|
import { fetch as realFetch } from "undici";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
let testPort = 0;
|
||||||
|
let reachable = false;
|
||||||
|
let cfgAttachOnly = false;
|
||||||
|
let createTargetId: string | null = null;
|
||||||
|
let screenshotThrowsOnce = false;
|
||||||
|
|
||||||
|
function makeProc(pid = 123) {
|
||||||
|
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||||
|
return {
|
||||||
|
pid,
|
||||||
|
killed: false,
|
||||||
|
exitCode: null as number | null,
|
||||||
|
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||||
|
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
emitExit: () => {
|
||||||
|
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||||
|
},
|
||||||
|
kill: () => {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = makeProc();
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
loadConfig: () => ({
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
|
color: "#FF4500",
|
||||||
|
attachOnly: cfgAttachOnly,
|
||||||
|
headless: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||||
|
vi.mock("./chrome.js", () => ({
|
||||||
|
isChromeReachable: vi.fn(async () => reachable),
|
||||||
|
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
|
||||||
|
launchCalls.push({ port: resolved.cdpPort });
|
||||||
|
reachable = true;
|
||||||
|
return {
|
||||||
|
pid: 123,
|
||||||
|
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||||
|
userDataDir: "/tmp/clawd",
|
||||||
|
cdpPort: resolved.cdpPort,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
proc,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
stopClawdChrome: vi.fn(async () => {
|
||||||
|
reachable = false;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const evalCalls = vi.hoisted(() => [] as Array<string>);
|
||||||
|
let evalThrows = false;
|
||||||
|
vi.mock("./cdp.js", () => ({
|
||||||
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
|
if (createTargetId) return { targetId: createTargetId };
|
||||||
|
throw new Error("cdp disabled");
|
||||||
|
}),
|
||||||
|
evaluateJavaScript: vi.fn(async ({ expression }: { expression: string }) => {
|
||||||
|
evalCalls.push(expression);
|
||||||
|
if (evalThrows) {
|
||||||
|
return {
|
||||||
|
exceptionDetails: { text: "boom" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { result: { type: "string", value: "ok" } };
|
||||||
|
}),
|
||||||
|
getDomText: vi.fn(async () => ({ text: "<html/>" })),
|
||||||
|
querySelector: vi.fn(async () => ({ matches: [{ index: 0, tag: "a" }] })),
|
||||||
|
snapshotAria: vi.fn(async () => ({
|
||||||
|
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||||
|
})),
|
||||||
|
snapshotDom: vi.fn(async () => ({
|
||||||
|
nodes: [{ ref: "1", parentRef: null, depth: 0, tag: "html" }],
|
||||||
|
})),
|
||||||
|
captureScreenshot: vi.fn(async () => {
|
||||||
|
if (screenshotThrowsOnce) {
|
||||||
|
screenshotThrowsOnce = false;
|
||||||
|
throw new Error("jpeg failed");
|
||||||
|
}
|
||||||
|
return Buffer.from("jpg");
|
||||||
|
}),
|
||||||
|
captureScreenshotPng: vi.fn(async () => Buffer.from("png")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./pw-ai.js", () => ({
|
||||||
|
clickRefViaPlaywright: vi.fn(async () => {}),
|
||||||
|
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||||
|
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../media/store.js", () => ({
|
||||||
|
ensureMediaDir: vi.fn(async () => {}),
|
||||||
|
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./screenshot.js", () => ({
|
||||||
|
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||||
|
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||||
|
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||||
|
buffer: buf,
|
||||||
|
contentType: "image/png",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function getFreePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const s = createServer();
|
||||||
|
s.once("error", reject);
|
||||||
|
s.listen(0, "127.0.0.1", () => {
|
||||||
|
const port = (s.address() as AddressInfo).port;
|
||||||
|
s.close((err) => (err ? reject(err) : resolve(port)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(
|
||||||
|
body: unknown,
|
||||||
|
init?: { ok?: boolean; status?: number; text?: string },
|
||||||
|
): Response {
|
||||||
|
const ok = init?.ok ?? true;
|
||||||
|
const status = init?.status ?? 200;
|
||||||
|
const text = init?.text ?? "";
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => text,
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("browser control server", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
reachable = false;
|
||||||
|
cfgAttachOnly = false;
|
||||||
|
createTargetId = null;
|
||||||
|
screenshotThrowsOnce = false;
|
||||||
|
testPort = await getFreePort();
|
||||||
|
|
||||||
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
|
let putNewCalls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("/json/list")) {
|
||||||
|
if (!reachable) return makeResponse([]);
|
||||||
|
return makeResponse([
|
||||||
|
{
|
||||||
|
id: "abcd1234",
|
||||||
|
title: "Tab",
|
||||||
|
url: "https://example.com",
|
||||||
|
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||||
|
type: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "abce9999",
|
||||||
|
title: "Other",
|
||||||
|
url: "https://other",
|
||||||
|
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||||
|
type: "page",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (u.includes("/json/new?")) {
|
||||||
|
if (init?.method === "PUT") {
|
||||||
|
putNewCalls += 1;
|
||||||
|
if (putNewCalls === 1) {
|
||||||
|
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return makeResponse({
|
||||||
|
id: "newtab1",
|
||||||
|
title: "",
|
||||||
|
url: "about:blank",
|
||||||
|
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||||
|
type: "page",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||||
|
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||||
|
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
|
await stopBrowserControlServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves status + starts browser when requested", async () => {
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
const started = await startBrowserControlServerFromConfig();
|
||||||
|
expect(started?.port).toBe(testPort);
|
||||||
|
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
const s1 = (await realFetch(`${base}/`).then((r) => r.json())) as {
|
||||||
|
running: boolean;
|
||||||
|
pid: number | null;
|
||||||
|
};
|
||||||
|
expect(s1.running).toBe(false);
|
||||||
|
expect(s1.pid).toBe(null);
|
||||||
|
|
||||||
|
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||||
|
const s2 = (await realFetch(`${base}/`).then((r) => r.json())) as {
|
||||||
|
running: boolean;
|
||||||
|
pid: number | null;
|
||||||
|
chosenBrowser: string | null;
|
||||||
|
};
|
||||||
|
expect(s2.running).toBe(true);
|
||||||
|
expect(s2.pid).toBe(123);
|
||||||
|
expect(s2.chosenBrowser).toBe("chrome");
|
||||||
|
expect(launchCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tabs: list, open, focus conflict on ambiguous prefix", async () => {
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
|
||||||
|
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||||
|
const tabs = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
|
||||||
|
running: boolean;
|
||||||
|
tabs: Array<{ targetId: string }>;
|
||||||
|
};
|
||||||
|
expect(tabs.running).toBe(true);
|
||||||
|
expect(tabs.tabs.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const opened = await realFetch(`${base}/tabs/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url: "https://example.com" }),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
expect(opened).toMatchObject({ targetId: "newtab1" });
|
||||||
|
|
||||||
|
const focus = await realFetch(`${base}/tabs/focus`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: "abc" }),
|
||||||
|
});
|
||||||
|
expect(focus.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps JS exceptions to a 400 and returns results otherwise", async () => {
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||||
|
|
||||||
|
evalThrows = true;
|
||||||
|
const bad = await realFetch(`${base}/eval`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ js: "throw 1" }),
|
||||||
|
});
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
|
||||||
|
evalThrows = false;
|
||||||
|
const ok = (await realFetch(`${base}/eval`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ js: "1+1", await: true }),
|
||||||
|
}).then((r) => r.json())) as { ok: boolean; result?: unknown };
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
expect(evalCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports query/dom/snapshot/click/screenshot and stop", async () => {
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||||
|
|
||||||
|
const query = (await realFetch(`${base}/query?selector=a&limit=1`).then(
|
||||||
|
(r) => r.json(),
|
||||||
|
)) as { ok: boolean; matches?: unknown[] };
|
||||||
|
expect(query.ok).toBe(true);
|
||||||
|
expect(Array.isArray(query.matches)).toBe(true);
|
||||||
|
|
||||||
|
const dom = (await realFetch(`${base}/dom?format=text&maxChars=10`).then(
|
||||||
|
(r) => r.json(),
|
||||||
|
)) as { ok: boolean; text?: string };
|
||||||
|
expect(dom.ok).toBe(true);
|
||||||
|
expect(typeof dom.text).toBe("string");
|
||||||
|
|
||||||
|
const snapAria = (await realFetch(
|
||||||
|
`${base}/snapshot?format=aria&limit=1`,
|
||||||
|
).then((r) => r.json())) as {
|
||||||
|
ok: boolean;
|
||||||
|
format?: string;
|
||||||
|
nodes?: unknown[];
|
||||||
|
};
|
||||||
|
expect(snapAria.ok).toBe(true);
|
||||||
|
expect(snapAria.format).toBe("aria");
|
||||||
|
|
||||||
|
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) =>
|
||||||
|
r.json(),
|
||||||
|
)) as { ok: boolean; format?: string; snapshot?: string };
|
||||||
|
expect(snapAi.ok).toBe(true);
|
||||||
|
expect(snapAi.format).toBe("ai");
|
||||||
|
|
||||||
|
const click = (await realFetch(`${base}/click`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ref: "1" }),
|
||||||
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
|
expect(click.ok).toBe(true);
|
||||||
|
|
||||||
|
const shot = (await realFetch(`${base}/screenshot?fullPage=true`).then(
|
||||||
|
(r) => r.json(),
|
||||||
|
)) as { ok: boolean; path?: string };
|
||||||
|
expect(shot.ok).toBe(true);
|
||||||
|
expect(typeof shot.path).toBe("string");
|
||||||
|
|
||||||
|
const stopped = (await realFetch(`${base}/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
}).then((r) => r.json())) as { ok: boolean; stopped?: boolean };
|
||||||
|
expect(stopped.ok).toBe(true);
|
||||||
|
expect(stopped.stopped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers common error branches", async () => {
|
||||||
|
cfgAttachOnly = true;
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
|
||||||
|
const missing = await realFetch(`${base}/tabs/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
|
||||||
|
reachable = false;
|
||||||
|
const started = (await realFetch(`${base}/start`, {
|
||||||
|
method: "POST",
|
||||||
|
}).then((r) => r.json())) as { error?: string };
|
||||||
|
expect(started.error ?? "").toMatch(/attachOnly/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens tabs via CDP createTarget path and falls back to PNG screenshots", async () => {
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||||
|
|
||||||
|
createTargetId = "abcd1234";
|
||||||
|
const opened = (await realFetch(`${base}/tabs/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url: "https://example.com" }),
|
||||||
|
}).then((r) => r.json())) as { targetId?: string };
|
||||||
|
expect(opened.targetId).toBe("abcd1234");
|
||||||
|
|
||||||
|
screenshotThrowsOnce = true;
|
||||||
|
const shot = (await realFetch(`${base}/screenshot`).then((r) =>
|
||||||
|
r.json(),
|
||||||
|
)) as { ok: boolean; path?: string };
|
||||||
|
expect(shot.ok).toBe(true);
|
||||||
|
expect(typeof shot.path).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers additional endpoint branches", async () => {
|
||||||
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = `http://127.0.0.1:${testPort}`;
|
||||||
|
|
||||||
|
const tabsWhenStopped = (await realFetch(`${base}/tabs`).then((r) =>
|
||||||
|
r.json(),
|
||||||
|
)) as { running: boolean; tabs: unknown[] };
|
||||||
|
expect(tabsWhenStopped.running).toBe(false);
|
||||||
|
expect(Array.isArray(tabsWhenStopped.tabs)).toBe(true);
|
||||||
|
|
||||||
|
const focusStopped = await realFetch(`${base}/tabs/focus`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: "abcd" }),
|
||||||
|
});
|
||||||
|
expect(focusStopped.status).toBe(409);
|
||||||
|
|
||||||
|
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||||
|
|
||||||
|
const focusMissing = await realFetch(`${base}/tabs/focus`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: "zzz" }),
|
||||||
|
});
|
||||||
|
expect(focusMissing.status).toBe(404);
|
||||||
|
|
||||||
|
const delAmbiguous = await realFetch(`${base}/tabs/abc`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(delAmbiguous.status).toBe(409);
|
||||||
|
|
||||||
|
const shotAmbiguous = await realFetch(`${base}/screenshot?targetId=abc`);
|
||||||
|
expect(shotAmbiguous.status).toBe(409);
|
||||||
|
|
||||||
|
const evalMissing = await realFetch(`${base}/eval`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(evalMissing.status).toBe(400);
|
||||||
|
|
||||||
|
const queryMissing = await realFetch(`${base}/query`);
|
||||||
|
expect(queryMissing.status).toBe(400);
|
||||||
|
|
||||||
|
const snapDom = (await realFetch(
|
||||||
|
`${base}/snapshot?format=domSnapshot&limit=1`,
|
||||||
|
).then((r) => r.json())) as { ok: boolean; format?: string };
|
||||||
|
expect(snapDom.ok).toBe(true);
|
||||||
|
expect(snapDom.format).toBe("domSnapshot");
|
||||||
|
});
|
||||||
|
});
|
||||||
177
src/cli/gateway-cli.coverage.test.ts
Normal file
177
src/cli/gateway-cli.coverage.test.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||||
|
const randomIdempotencyKey = vi.fn(() => "rk_test");
|
||||||
|
const startGatewayServer = vi.fn(async () => ({
|
||||||
|
close: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
const setVerbose = vi.fn();
|
||||||
|
const createDefaultDeps = vi.fn();
|
||||||
|
const forceFreePort = vi.fn(() => []);
|
||||||
|
|
||||||
|
const runtimeLogs: string[] = [];
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
const defaultRuntime = {
|
||||||
|
log: (msg: string) => runtimeLogs.push(msg),
|
||||||
|
error: (msg: string) => runtimeErrors.push(msg),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`__exit__:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway: (opts: unknown) => callGateway(opts),
|
||||||
|
randomIdempotencyKey: () => randomIdempotencyKey(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../gateway/server.js", () => ({
|
||||||
|
startGatewayServer: (port: number, opts: unknown) =>
|
||||||
|
startGatewayServer(port, opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../globals.js", () => ({
|
||||||
|
info: (msg: string) => msg,
|
||||||
|
setVerbose: (enabled: boolean) => setVerbose(enabled),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../runtime.js", () => ({
|
||||||
|
defaultRuntime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./deps.js", () => ({
|
||||||
|
createDefaultDeps: () => createDefaultDeps(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ports.js", () => ({
|
||||||
|
forceFreePort: () => forceFreePort(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("gateway-cli coverage", () => {
|
||||||
|
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
callGateway.mockClear();
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["gateway", "call", "health", "--params", '{"x":1}'],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails gateway call on invalid params JSON", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
callGateway.mockClear();
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(
|
||||||
|
["gateway", "call", "status", "--params", "not-json"],
|
||||||
|
{ from: "user" },
|
||||||
|
),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
expect(callGateway).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeErrors.join("\n")).toContain("Gateway call failed:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills idempotency keys for send/agent when missing", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
callGateway.mockClear();
|
||||||
|
randomIdempotencyKey.mockClear();
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["gateway", "send", "--to", "+1555", "--message", "hi"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["gateway", "agent", "--message", "hello", "--deliver"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(randomIdempotencyKey).toHaveBeenCalled();
|
||||||
|
const callArgs = callGateway.mock.calls.map((c) => c[0]) as Array<{
|
||||||
|
method: string;
|
||||||
|
params?: { idempotencyKey?: string };
|
||||||
|
expectFinal?: boolean;
|
||||||
|
}>;
|
||||||
|
expect(callArgs.some((c) => c.method === "send")).toBe(true);
|
||||||
|
expect(
|
||||||
|
callArgs.some((c) => c.method === "agent" && c.expectFinal === true),
|
||||||
|
).toBe(true);
|
||||||
|
expect(callArgs.every((c) => c.params?.idempotencyKey === "rk_test")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates gateway ports and handles force/start errors", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
|
||||||
|
// Invalid port
|
||||||
|
const programInvalidPort = new Command();
|
||||||
|
programInvalidPort.exitOverride();
|
||||||
|
registerGatewayCli(programInvalidPort);
|
||||||
|
await expect(
|
||||||
|
programInvalidPort.parseAsync(["gateway", "--port", "0"], {
|
||||||
|
from: "user",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
// Force free failure
|
||||||
|
forceFreePort.mockImplementationOnce(() => {
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
const programForceFail = new Command();
|
||||||
|
programForceFail.exitOverride();
|
||||||
|
registerGatewayCli(programForceFail);
|
||||||
|
await expect(
|
||||||
|
programForceFail.parseAsync(["gateway", "--port", "18789", "--force"], {
|
||||||
|
from: "user",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
// Start failure (generic)
|
||||||
|
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
||||||
|
const programStartFail = new Command();
|
||||||
|
programStartFail.exitOverride();
|
||||||
|
registerGatewayCli(programStartFail);
|
||||||
|
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
||||||
|
const beforeSigint = new Set(process.listeners("SIGINT"));
|
||||||
|
await expect(
|
||||||
|
programStartFail.parseAsync(["gateway", "--port", "18789"], {
|
||||||
|
from: "user",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
for (const listener of process.listeners("SIGTERM")) {
|
||||||
|
if (!beforeSigterm.has(listener))
|
||||||
|
process.removeListener("SIGTERM", listener);
|
||||||
|
}
|
||||||
|
for (const listener of process.listeners("SIGINT")) {
|
||||||
|
if (!beforeSigint.has(listener))
|
||||||
|
process.removeListener("SIGINT", listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/commands/health.command.coverage.test.ts
Normal file
68
src/commands/health.command.coverage.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { HealthSummary } from "./health.js";
|
||||||
|
import { healthCommand } from "./health.js";
|
||||||
|
|
||||||
|
const callGatewayMock = vi.fn();
|
||||||
|
const logWebSelfIdMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway: (...args: unknown[]) => callGatewayMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../web/session.js", () => ({
|
||||||
|
webAuthExists: vi.fn(async () => true),
|
||||||
|
getWebAuthAgeMs: vi.fn(() => 0),
|
||||||
|
logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("healthCommand (coverage)", () => {
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the rich text summary when linked and configured", async () => {
|
||||||
|
callGatewayMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
ts: Date.now(),
|
||||||
|
durationMs: 5,
|
||||||
|
web: {
|
||||||
|
linked: true,
|
||||||
|
authAgeMs: 5 * 60_000,
|
||||||
|
connect: { ok: true, status: 200, elapsedMs: 10 },
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
configured: true,
|
||||||
|
probe: {
|
||||||
|
ok: true,
|
||||||
|
elapsedMs: 7,
|
||||||
|
bot: { username: "bot" },
|
||||||
|
webhook: { url: "https://example.com/h" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
heartbeatSeconds: 60,
|
||||||
|
sessions: {
|
||||||
|
path: "/tmp/sessions.json",
|
||||||
|
count: 2,
|
||||||
|
recent: [
|
||||||
|
{ key: "main", updatedAt: Date.now() - 60_000, age: 60_000 },
|
||||||
|
{ key: "foo", updatedAt: null, age: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies HealthSummary);
|
||||||
|
|
||||||
|
await healthCommand({ json: false, timeoutMs: 1000 }, runtime as never);
|
||||||
|
|
||||||
|
expect(runtime.exit).not.toHaveBeenCalled();
|
||||||
|
expect(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(
|
||||||
|
/Web: linked/i,
|
||||||
|
);
|
||||||
|
expect(logWebSelfIdMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
141
src/commands/health.snapshot.test.ts
Normal file
141
src/commands/health.snapshot.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { HealthSummary } from "./health.js";
|
||||||
|
import { getHealthSnapshot } from "./health.js";
|
||||||
|
|
||||||
|
let testConfig: Record<string, unknown> = {};
|
||||||
|
let testStore: Record<string, { updatedAt?: number }> = {};
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
loadConfig: () => testConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/sessions.js", () => ({
|
||||||
|
resolveStorePath: () => "/tmp/sessions.json",
|
||||||
|
loadSessionStore: () => testStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../web/session.js", () => ({
|
||||||
|
webAuthExists: vi.fn(async () => true),
|
||||||
|
getWebAuthAgeMs: vi.fn(() => 1234),
|
||||||
|
logWebSelfId: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../web/reconnect.js", () => ({
|
||||||
|
resolveHeartbeatSeconds: vi.fn(() => 60),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getHealthSnapshot", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips telegram probe when not configured", async () => {
|
||||||
|
testConfig = { inbound: { reply: { session: { store: "/tmp/x" } } } };
|
||||||
|
testStore = {
|
||||||
|
global: { updatedAt: Date.now() },
|
||||||
|
unknown: { updatedAt: Date.now() },
|
||||||
|
main: { updatedAt: 1000 },
|
||||||
|
foo: { updatedAt: 2000 },
|
||||||
|
};
|
||||||
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
|
||||||
|
const snap = (await getHealthSnapshot(10)) satisfies HealthSummary;
|
||||||
|
expect(snap.ok).toBe(true);
|
||||||
|
expect(snap.telegram.configured).toBe(false);
|
||||||
|
expect(snap.telegram.probe).toBeUndefined();
|
||||||
|
expect(snap.sessions.count).toBe(2);
|
||||||
|
expect(snap.sessions.recent[0]?.key).toBe("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes telegram getMe + webhook info when configured", async () => {
|
||||||
|
testConfig = { telegram: { botToken: "t-1" } };
|
||||||
|
testStore = {};
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string) => {
|
||||||
|
calls.push(url);
|
||||||
|
if (url.includes("/getMe")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
result: { id: 1, username: "bot" },
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
if (url.includes("/getWebhookInfo")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
url: "https://example.com/h",
|
||||||
|
has_custom_certificate: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ ok: false, description: "nope" }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snap = await getHealthSnapshot(25);
|
||||||
|
expect(snap.telegram.configured).toBe(true);
|
||||||
|
expect(snap.telegram.probe?.ok).toBe(true);
|
||||||
|
expect(snap.telegram.probe?.bot?.username).toBe("bot");
|
||||||
|
expect(snap.telegram.probe?.webhook?.url).toMatch(/^https:/);
|
||||||
|
expect(calls.some((c) => c.includes("/getMe"))).toBe(true);
|
||||||
|
expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a structured telegram probe error when getMe fails", async () => {
|
||||||
|
testConfig = { telegram: { botToken: "bad-token" } };
|
||||||
|
testStore = {};
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string) => {
|
||||||
|
if (url.includes("/getMe")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ ok: false, description: "unauthorized" }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
throw new Error("unexpected");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snap = await getHealthSnapshot(25);
|
||||||
|
expect(snap.telegram.configured).toBe(true);
|
||||||
|
expect(snap.telegram.probe?.ok).toBe(false);
|
||||||
|
expect(snap.telegram.probe?.status).toBe(401);
|
||||||
|
expect(snap.telegram.probe?.error).toMatch(/unauthorized/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures unexpected probe exceptions as errors", async () => {
|
||||||
|
testConfig = { telegram: { botToken: "t-err" } };
|
||||||
|
testStore = {};
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error("network down");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snap = await getHealthSnapshot(25);
|
||||||
|
expect(snap.telegram.configured).toBe(true);
|
||||||
|
expect(snap.telegram.probe?.ok).toBe(false);
|
||||||
|
expect(snap.telegram.probe?.error).toMatch(/network down/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/infra/clawdis-mac.test.ts
Normal file
124
src/infra/clawdis-mac.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import fsp from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
|
const runExecCalls = vi.hoisted(
|
||||||
|
() => [] as Array<{ cmd: string; args: string[] }>,
|
||||||
|
);
|
||||||
|
const runCommandCalls = vi.hoisted(
|
||||||
|
() => [] as Array<{ argv: string[]; timeoutMs: number }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
let runExecThrows = false;
|
||||||
|
|
||||||
|
vi.mock("../process/exec.js", () => ({
|
||||||
|
runExec: vi.fn(async (cmd: string, args: string[]) => {
|
||||||
|
runExecCalls.push({ cmd, args });
|
||||||
|
if (runExecThrows) throw new Error("which failed");
|
||||||
|
return { stdout: "/usr/local/bin/clawdis-mac\n", stderr: "" };
|
||||||
|
}),
|
||||||
|
runCommandWithTimeout: vi.fn(async (argv: string[], timeoutMs: number) => {
|
||||||
|
runCommandCalls.push({ argv, timeoutMs });
|
||||||
|
return { stdout: "ok", stderr: "", code: 0 };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { resolveClawdisMacBinary, runClawdisMac } from "./clawdis-mac.js";
|
||||||
|
|
||||||
|
describe("clawdis-mac binary resolver", () => {
|
||||||
|
it("uses env override on macOS and errors elsewhere", async () => {
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac");
|
||||||
|
await expect(resolveClawdisMacBinary(runtime)).resolves.toBe(
|
||||||
|
"/opt/bin/clawdis-mac",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(resolveClawdisMacBinary(runtime)).rejects.toThrow(/exit 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the helper with --json when requested", async () => {
|
||||||
|
if (process.platform !== "darwin") return;
|
||||||
|
vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac");
|
||||||
|
|
||||||
|
const res = await runClawdisMac(["browser", "status"], {
|
||||||
|
json: true,
|
||||||
|
timeoutMs: 1234,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res).toMatchObject({ stdout: "ok", code: 0 });
|
||||||
|
expect(runCommandCalls.length).toBeGreaterThan(0);
|
||||||
|
expect(runCommandCalls.at(-1)?.argv).toEqual([
|
||||||
|
"/opt/bin/clawdis-mac",
|
||||||
|
"--json",
|
||||||
|
"browser",
|
||||||
|
"status",
|
||||||
|
]);
|
||||||
|
expect(runCommandCalls.at(-1)?.timeoutMs).toBe(1234);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to `which clawdis-mac` when no override is set", async () => {
|
||||||
|
if (process.platform !== "darwin") return;
|
||||||
|
vi.stubEnv("CLAWDIS_MAC_BIN", "");
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await resolveClawdisMacBinary(runtime);
|
||||||
|
expect(resolved).toBe("/usr/local/bin/clawdis-mac");
|
||||||
|
expect(runExecCalls.some((c) => c.cmd === "which")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to ./bin/clawdis-mac when which fails", async () => {
|
||||||
|
if (process.platform !== "darwin") return;
|
||||||
|
|
||||||
|
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdis-mac-test-"));
|
||||||
|
const oldCwd = process.cwd();
|
||||||
|
try {
|
||||||
|
const binDir = path.join(tmp, "bin");
|
||||||
|
await fsp.mkdir(binDir, { recursive: true });
|
||||||
|
const exePath = path.join(binDir, "clawdis-mac");
|
||||||
|
await fsp.writeFile(exePath, "#!/bin/sh\necho ok\n", "utf-8");
|
||||||
|
await fsp.chmod(exePath, 0o755);
|
||||||
|
|
||||||
|
process.chdir(tmp);
|
||||||
|
vi.stubEnv("CLAWDIS_MAC_BIN", "");
|
||||||
|
runExecThrows = true;
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await resolveClawdisMacBinary(runtime);
|
||||||
|
const expectedReal = await fsp.realpath(exePath);
|
||||||
|
const resolvedReal = await fsp.realpath(resolved);
|
||||||
|
expect(resolvedReal).toBe(expectedReal);
|
||||||
|
} finally {
|
||||||
|
runExecThrows = false;
|
||||||
|
process.chdir(oldCwd);
|
||||||
|
await fsp.rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user