test: dedupe infra utility suites

This commit is contained in:
Peter Steinberger
2026-03-27 23:33:03 +00:00
parent 36b9ec9418
commit eef2f82986
42 changed files with 1675 additions and 1426 deletions

View File

@@ -16,6 +16,11 @@ import { bindAbortRelay } from "../utils/fetch-timeout.js";
*/
describe("abort pattern: .bind() vs arrow closure (#7174)", () => {
function expectDefaultAbortReason(controller: AbortController): void {
expect(controller.signal.reason).toBeInstanceOf(DOMException);
expect(controller.signal.reason.name).toBe("AbortError");
}
it("controller.abort.bind(controller) aborts the signal", () => {
const controller = new AbortController();
const boundAbort = controller.abort.bind(controller);
@@ -47,9 +52,7 @@ describe("abort pattern: .bind() vs arrow closure (#7174)", () => {
parent.abort();
expect(child.signal.aborted).toBe(true);
// The reason must be the default AbortError, not the Event object
expect(child.signal.reason).toBeInstanceOf(DOMException);
expect(child.signal.reason.name).toBe("AbortError");
expectDefaultAbortReason(child);
});
it("raw .abort.bind() leaks Event as reason — bindAbortRelay() does not", () => {
@@ -66,9 +69,7 @@ describe("abort pattern: .bind() vs arrow closure (#7174)", () => {
const childB = new AbortController();
parentB.signal.addEventListener("abort", bindAbortRelay(childB), { once: true });
parentB.abort();
// childB.signal.reason IS the default AbortError
expect(childB.signal.reason).toBeInstanceOf(DOMException);
expect(childB.signal.reason.name).toBe("AbortError");
expectDefaultAbortReason(childB);
});
it("removeEventListener works with saved bindAbortRelay() reference", () => {
@@ -95,7 +96,6 @@ describe("abort pattern: .bind() vs arrow closure (#7174)", () => {
expect(combined.signal.aborted).toBe(false);
signalA.abort();
expect(combined.signal.aborted).toBe(true);
expect(combined.signal.reason).toBeInstanceOf(DOMException);
expect(combined.signal.reason.name).toBe("AbortError");
expectDefaultAbortReason(combined);
});
});

View File

@@ -14,6 +14,14 @@ import {
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-");
function expectTarPreflightError(
checker: ReturnType<typeof createTarEntryPreflightChecker>,
entry: Parameters<ReturnType<typeof createTarEntryPreflightChecker>>[0],
expected: string | RegExp,
): void {
expect(() => checker(entry)).toThrow(expected);
}
afterEach(async () => {
vi.useRealTimers();
await tempDirs.cleanup();
@@ -30,32 +38,53 @@ describe("archive helpers", () => {
expect(resolveArchiveKind(input)).toBe(expected);
});
it("resolves packed roots from package dir or single extracted root dir", async () => {
const directDir = await createTempDir();
const fallbackDir = await createTempDir();
const markerDir = await createTempDir();
await fs.mkdir(path.join(directDir, "package"), { recursive: true });
await fs.mkdir(path.join(fallbackDir, "bundle-root"), { recursive: true });
await fs.writeFile(path.join(markerDir, "package.json"), "{}", "utf8");
await expect(resolvePackedRootDir(directDir)).resolves.toBe(path.join(directDir, "package"));
await expect(resolvePackedRootDir(fallbackDir)).resolves.toBe(
path.join(fallbackDir, "bundle-root"),
);
await expect(resolvePackedRootDir(markerDir, { rootMarkers: ["package.json"] })).resolves.toBe(
markerDir,
);
it.each([
{
name: "uses the package directory when present",
setup: async (root: string) => {
await fs.mkdir(path.join(root, "package"), { recursive: true });
},
expected: (root: string) => path.join(root, "package"),
},
{
name: "uses the single extracted root directory as a fallback",
setup: async (root: string) => {
await fs.mkdir(path.join(root, "bundle-root"), { recursive: true });
},
expected: (root: string) => path.join(root, "bundle-root"),
},
{
name: "uses the extraction root when a root marker is present",
setup: async (root: string) => {
await fs.writeFile(path.join(root, "package.json"), "{}", "utf8");
},
opts: { rootMarkers: ["package.json"] },
expected: (root: string) => root,
},
])("resolves packed roots when $name", async ({ setup, expected, opts }) => {
const root = await createTempDir();
await setup(root);
await expect(resolvePackedRootDir(root, opts)).resolves.toBe(expected(root));
});
it("rejects unexpected packed root layouts", async () => {
const multipleDir = await createTempDir();
const emptyDir = await createTempDir();
await fs.mkdir(path.join(multipleDir, "a"), { recursive: true });
await fs.mkdir(path.join(multipleDir, "b"), { recursive: true });
await fs.writeFile(path.join(emptyDir, "note.txt"), "hi", "utf8");
await expect(resolvePackedRootDir(multipleDir)).rejects.toThrow(/unexpected archive layout/i);
await expect(resolvePackedRootDir(emptyDir)).rejects.toThrow(/unexpected archive layout/i);
it.each([
{
name: "multiple extracted roots exist",
setup: async (root: string) => {
await fs.mkdir(path.join(root, "a"), { recursive: true });
await fs.mkdir(path.join(root, "b"), { recursive: true });
},
},
{
name: "only non-root marker files exist",
setup: async (root: string) => {
await fs.writeFile(path.join(root, "note.txt"), "hi", "utf8");
},
},
])("rejects unexpected packed root layouts when $name", async ({ setup }) => {
const root = await createTempDir();
await setup(root);
await expect(resolvePackedRootDir(root)).rejects.toThrow(/unexpected archive layout/i);
});
it("returns work results and propagates errors before timeout", async () => {
@@ -84,15 +113,21 @@ describe("archive helpers", () => {
},
});
expect(() => checker({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow(
expectTarPreflightError(
checker,
{ path: "package/link", type: "SymbolicLink", size: 0 },
"tar entry is a link: package/link",
);
expect(() => checker({ path: "../escape.txt", type: "File", size: 1 })).toThrow(
expectTarPreflightError(
checker,
{ path: "../escape.txt", type: "File", size: 1 },
/escapes destination|absolute/i,
);
checker({ path: "package/ok.txt", type: "File", size: 8 });
expect(() => checker({ path: "package/second.txt", type: "File", size: 1 })).toThrow(
expectTarPreflightError(
checker,
{ path: "package/second.txt", type: "File", size: 1 },
"archive entry count exceeds limit",
);
});
@@ -110,7 +145,9 @@ describe("archive helpers", () => {
expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow();
checker({ path: "package/a.txt", type: "File", size: 6 });
expect(() => checker({ path: "package/b.txt", type: "File", size: 6 })).toThrow(
expectTarPreflightError(
checker,
{ path: "package/b.txt", type: "File", size: 6 },
"archive extracted size exceeds limit",
);
});

View File

@@ -86,12 +86,14 @@ describe("archive path helpers", () => {
);
});
const rootDir = path.join(path.sep, "tmp", "archive-root");
it.each([
{
name: "keeps resolved output paths inside the root",
relPath: "sub/file.txt",
originalPath: "sub/file.txt",
expected: path.resolve(path.join(path.sep, "tmp", "archive-root"), "sub/file.txt"),
expected: path.resolve(rootDir, "sub/file.txt"),
},
{
name: "rejects output paths that escape the root",
@@ -101,7 +103,6 @@ describe("archive path helpers", () => {
message: "archive entry escapes targetDir: ../escape.txt",
},
])("$name", ({ relPath, originalPath, escapeLabel, expected, message }) => {
const rootDir = path.join(path.sep, "tmp", "archive-root");
if (message) {
expectArchivePathError(
() =>

View File

@@ -17,9 +17,12 @@ function makeResult(overrides: Partial<BackupCreateResult> = {}): BackupCreateRe
}
describe("formatBackupCreateSummary", () => {
it("formats created archives with included and skipped paths", () => {
const lines = formatBackupCreateSummary(
makeResult({
const backupArchiveLine = "Backup archive: /tmp/openclaw-backup.tar.gz";
it.each([
{
name: "formats created archives with included and skipped paths",
result: makeResult({
verified: true,
assets: [
{
@@ -39,22 +42,19 @@ describe("formatBackupCreateSummary", () => {
},
],
}),
);
expect(lines).toEqual([
"Backup archive: /tmp/openclaw-backup.tar.gz",
"Included 1 path:",
"- state: ~/.openclaw",
"Skipped 1 path:",
"- workspace: ~/Projects/openclaw (covered by ~/.openclaw)",
"Created /tmp/openclaw-backup.tar.gz",
"Archive verification: passed",
]);
});
it("formats dry runs and pluralized counts", () => {
const lines = formatBackupCreateSummary(
makeResult({
expected: [
backupArchiveLine,
"Included 1 path:",
"- state: ~/.openclaw",
"Skipped 1 path:",
"- workspace: ~/Projects/openclaw (covered by ~/.openclaw)",
"Created /tmp/openclaw-backup.tar.gz",
"Archive verification: passed",
],
},
{
name: "formats dry runs and pluralized counts",
result: makeResult({
dryRun: true,
assets: [
{
@@ -71,14 +71,15 @@ describe("formatBackupCreateSummary", () => {
},
],
}),
);
expect(lines).toEqual([
"Backup archive: /tmp/openclaw-backup.tar.gz",
"Included 2 paths:",
"- config: ~/.openclaw/config.json",
"- credentials: ~/.openclaw/oauth",
"Dry run only; archive was not written.",
]);
expected: [
backupArchiveLine,
"Included 2 paths:",
"- config: ~/.openclaw/config.json",
"- credentials: ~/.openclaw/oauth",
"Dry run only; archive was not written.",
],
},
])("$name", ({ result, expected }) => {
expect(formatBackupCreateSummary(result)).toEqual(expected);
});
});

View File

@@ -2,63 +2,75 @@ import { describe, expect, it } from "vitest";
import { resolveCanvasHostUrl } from "./canvas-host-url.js";
describe("resolveCanvasHostUrl", () => {
it("returns undefined when no canvas port or usable host is available", () => {
expect(resolveCanvasHostUrl({})).toBeUndefined();
expect(resolveCanvasHostUrl({ canvasPort: 3000, hostOverride: "127.0.0.1" })).toBeUndefined();
});
it("prefers non-loopback host overrides and preserves explicit ports", () => {
expect(
resolveCanvasHostUrl({
it.each([
{
name: "returns undefined when no canvas port is available",
params: {},
expected: undefined,
},
{
name: "returns undefined when only a loopback host override is available",
params: { canvasPort: 3000, hostOverride: "127.0.0.1" },
expected: undefined,
},
{
name: "prefers non-loopback host overrides and preserves explicit ports",
params: {
canvasPort: 3000,
hostOverride: " canvas.openclaw.ai ",
requestHost: "gateway.local:9000",
localAddress: "192.168.1.10",
}),
).toBe("http://canvas.openclaw.ai:3000");
});
it("falls back from rejected loopback overrides to request hosts", () => {
expect(
resolveCanvasHostUrl({
},
expected: "http://canvas.openclaw.ai:3000",
},
{
name: "falls back from rejected loopback overrides to request hosts",
params: {
canvasPort: 3000,
hostOverride: "127.0.0.1",
requestHost: "example.com:8443",
}),
).toBe("http://example.com:3000");
});
it("maps proxied default gateway ports to request-host ports or scheme defaults", () => {
expect(
resolveCanvasHostUrl({
},
expected: "http://example.com:3000",
},
{
name: "maps proxied default gateway ports to request-host ports",
params: {
canvasPort: 18789,
requestHost: "gateway.example.com:9443",
forwardedProto: "https",
}),
).toBe("https://gateway.example.com:9443");
expect(
resolveCanvasHostUrl({
},
expected: "https://gateway.example.com:9443",
},
{
name: "maps proxied default gateway ports to scheme defaults",
params: {
canvasPort: 18789,
requestHost: "gateway.example.com",
forwardedProto: ["https", "http"],
}),
).toBe("https://gateway.example.com:443");
expect(
resolveCanvasHostUrl({
},
expected: "https://gateway.example.com:443",
},
{
name: "uses http scheme defaults without forwarded proto",
params: {
canvasPort: 18789,
requestHost: "gateway.example.com",
}),
).toBe("http://gateway.example.com:80");
});
it("brackets ipv6 hosts and can fall back to local addresses", () => {
expect(
resolveCanvasHostUrl({
},
expected: "http://gateway.example.com:80",
},
{
name: "brackets ipv6 hosts and can fall back to local addresses",
params: {
canvasPort: 3000,
requestHost: "not a host",
localAddress: "2001:db8::1",
scheme: "https",
}),
).toBe("https://[2001:db8::1]:3000");
},
expected: "https://[2001:db8::1]:3000",
},
])("$name", ({ params, expected }) => {
expect(resolveCanvasHostUrl(params as Parameters<typeof resolveCanvasHostUrl>[0])).toBe(
expected,
);
});
});

View File

@@ -4,41 +4,53 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { detectPackageManager } from "./detect-package-manager.js";
async function createPackageManagerRoot(
files: Array<{ path: string; content: string }>,
): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
for (const file of files) {
await fs.writeFile(path.join(root, file.path), file.content, "utf8");
}
return root;
}
describe("detectPackageManager", () => {
it("prefers packageManager from package.json when supported", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ packageManager: "pnpm@10.8.1" }),
"utf8",
);
await fs.writeFile(path.join(root, "package-lock.json"), "", "utf8");
const root = await createPackageManagerRoot([
{ path: "package.json", content: JSON.stringify({ packageManager: "pnpm@10.8.1" }) },
{ path: "package-lock.json", content: "" },
]);
await expect(detectPackageManager(root)).resolves.toBe("pnpm");
});
it("falls back to lockfiles when package.json is missing or unsupported", async () => {
const bunRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
await fs.writeFile(path.join(bunRoot, "bun.lock"), "", "utf8");
await expect(detectPackageManager(bunRoot)).resolves.toBe("bun");
const legacyBunRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
await fs.writeFile(path.join(legacyBunRoot, "bun.lockb"), "", "utf8");
await expect(detectPackageManager(legacyBunRoot)).resolves.toBe("bun");
const npmRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
await fs.writeFile(
path.join(npmRoot, "package.json"),
JSON.stringify({ packageManager: "yarn@4.0.0" }),
"utf8",
it.each([
{
name: "uses bun.lock",
files: [{ path: "bun.lock", content: "" }],
expected: "bun",
},
{
name: "uses bun.lockb",
files: [{ path: "bun.lockb", content: "" }],
expected: "bun",
},
{
name: "falls back to npm lockfiles for unsupported packageManager values",
files: [
{ path: "package.json", content: JSON.stringify({ packageManager: "yarn@4.0.0" }) },
{ path: "package-lock.json", content: "" },
],
expected: "npm",
},
])("falls back to lockfiles when $name", async ({ files, expected }) => {
await expect(detectPackageManager(await createPackageManagerRoot(files))).resolves.toBe(
expected,
);
await fs.writeFile(path.join(npmRoot, "package-lock.json"), "", "utf8");
await expect(detectPackageManager(npmRoot)).resolves.toBe("npm");
});
it("returns null when no package manager markers exist", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
await fs.writeFile(path.join(root, "package.json"), "{not-json}", "utf8");
const root = await createPackageManagerRoot([{ path: "package.json", content: "{not-json}" }]);
await expect(detectPackageManager(root)).resolves.toBeNull();
});

View File

@@ -9,16 +9,28 @@ import {
readErrorName,
} from "./errors.js";
describe("error helpers", () => {
it("extracts codes and names from string and numeric error metadata", () => {
expect(extractErrorCode({ code: "EADDRINUSE" })).toBe("EADDRINUSE");
expect(extractErrorCode({ code: 429 })).toBe("429");
expect(extractErrorCode({ code: false })).toBeUndefined();
expect(extractErrorCode("boom")).toBeUndefined();
function createCircularObject() {
const circular: { self?: unknown } = {};
circular.self = circular;
return circular;
}
expect(readErrorName({ name: "AbortError" })).toBe("AbortError");
expect(readErrorName({ name: 42 })).toBe("");
expect(readErrorName(null)).toBe("");
describe("error helpers", () => {
it.each([
{ value: { code: "EADDRINUSE" }, expected: "EADDRINUSE" },
{ value: { code: 429 }, expected: "429" },
{ value: { code: false }, expected: undefined },
{ value: "boom", expected: undefined },
])("extracts error codes from %j", ({ value, expected }) => {
expect(extractErrorCode(value)).toBe(expected);
});
it.each([
{ value: { name: "AbortError" }, expected: "AbortError" },
{ value: { name: 42 }, expected: "" },
{ value: null, expected: "" },
])("reads error names from %j", ({ value, expected }) => {
expect(readErrorName(value)).toBe(expected);
});
it("walks nested error graphs once in breadth-first order", () => {
@@ -48,13 +60,12 @@ describe("error helpers", () => {
expect(isErrno("busy")).toBe(false);
});
it("formats primitives and circular objects without throwing", () => {
const circular: { self?: unknown } = {};
circular.self = circular;
expect(formatErrorMessage(123n)).toBe("123");
expect(formatErrorMessage(false)).toBe("false");
expect(formatErrorMessage(circular)).toBe("[object Object]");
it.each([
{ value: 123n, expected: "123" },
{ value: false, expected: "false" },
{ value: createCircularObject(), expected: "[object Object]" },
])("formats error messages for case %#", ({ value, expected }) => {
expect(formatErrorMessage(value)).toBe(expected);
});
it("redacts sensitive tokens from formatted error messages", () => {

View File

@@ -5,36 +5,33 @@ import {
} from "./exec-approval-command-display.js";
describe("sanitizeExecApprovalDisplayText", () => {
it("escapes unicode format characters but leaves other text intact", () => {
expect(sanitizeExecApprovalDisplayText("echo hi\u200Bthere")).toBe("echo hi\\u{200B}there");
});
it("escapes visually blank hangul filler characters used for spoofing", () => {
expect(sanitizeExecApprovalDisplayText("date\u3164\uFFA0\u115F\u1160가")).toBe(
"date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가",
);
it.each([
["echo hi\u200Bthere", "echo hi\\u{200B}there"],
["date\u3164\uFFA0\u115F\u1160가", "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가"],
])("sanitizes exec approval display text for %j", (input, expected) => {
expect(sanitizeExecApprovalDisplayText(input)).toBe(expected);
});
});
describe("resolveExecApprovalCommandDisplay", () => {
it("prefers explicit command fields and drops identical previews after trimming", () => {
expect(
resolveExecApprovalCommandDisplay({
it.each([
{
name: "prefers explicit command fields and drops identical previews after trimming",
input: {
command: "echo hi",
commandPreview: " echo hi ",
host: "gateway",
}),
).toEqual({
commandText: "echo hi",
commandPreview: null,
});
});
it("falls back to node systemRunPlan values and sanitizes preview text", () => {
expect(
resolveExecApprovalCommandDisplay({
host: "gateway" as const,
},
expected: {
commandText: "echo hi",
commandPreview: null,
},
},
{
name: "falls back to node systemRunPlan values and sanitizes preview text",
input: {
command: "",
host: "node",
host: "node" as const,
systemRunPlan: {
argv: ["python3", "-c", "print(1)"],
cwd: null,
@@ -43,18 +40,17 @@ describe("resolveExecApprovalCommandDisplay", () => {
agentId: null,
sessionKey: null,
},
}),
).toEqual({
commandText: 'python3 -c "print(1)"',
commandPreview: "print\\u{200B}(1)",
});
});
it("ignores systemRunPlan fallback for non-node hosts", () => {
expect(
resolveExecApprovalCommandDisplay({
},
expected: {
commandText: 'python3 -c "print(1)"',
commandPreview: "print\\u{200B}(1)",
},
},
{
name: "ignores systemRunPlan fallback for non-node hosts",
input: {
command: "",
host: "sandbox",
host: "sandbox" as const,
systemRunPlan: {
argv: ["echo", "hi"],
cwd: null,
@@ -63,10 +59,13 @@ describe("resolveExecApprovalCommandDisplay", () => {
agentId: null,
sessionKey: null,
},
}),
).toEqual({
commandText: "",
commandPreview: null,
});
},
expected: {
commandText: "",
commandPreview: null,
},
},
])("$name", ({ input, expected }) => {
expect(resolveExecApprovalCommandDisplay(input)).toEqual(expected);
});
});

View File

@@ -2,21 +2,20 @@ import { describe, expect, it } from "vitest";
import { isSafeExecutableValue } from "./exec-safety.js";
describe("isSafeExecutableValue", () => {
it("accepts bare executable names and likely paths", () => {
expect(isSafeExecutableValue("node")).toBe(true);
expect(isSafeExecutableValue("/usr/bin/node")).toBe(true);
expect(isSafeExecutableValue("./bin/openclaw")).toBe(true);
expect(isSafeExecutableValue("C:\\Tools\\openclaw.exe")).toBe(true);
expect(isSafeExecutableValue(" tool ")).toBe(true);
});
it("rejects blanks, flags, shell metacharacters, quotes, and control chars", () => {
expect(isSafeExecutableValue(undefined)).toBe(false);
expect(isSafeExecutableValue(" ")).toBe(false);
expect(isSafeExecutableValue("-rf")).toBe(false);
expect(isSafeExecutableValue("node;rm -rf /")).toBe(false);
expect(isSafeExecutableValue('node "arg"')).toBe(false);
expect(isSafeExecutableValue("node\nnext")).toBe(false);
expect(isSafeExecutableValue("node\0")).toBe(false);
it.each([
["node", true],
["/usr/bin/node", true],
["./bin/openclaw", true],
["C:\\Tools\\openclaw.exe", true],
[" tool ", true],
[undefined, false],
[" ", false],
["-rf", false],
["node;rm -rf /", false],
['node "arg"', false],
["node\nnext", false],
["node\0", false],
])("classifies executable value %j", (value, expected) => {
expect(isSafeExecutableValue(value)).toBe(expected);
});
});

View File

@@ -10,52 +10,52 @@ import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js";
const invalidDurationInputs = [null, undefined, -100] as const;
function expectFormatterCases<TInput, TOutput>(
formatter: (value: TInput) => TOutput,
cases: ReadonlyArray<{ input: TInput; expected: TOutput }>,
) {
for (const { input, expected } of cases) {
expect(formatter(input), String(input)).toBe(expected);
}
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("format-duration", () => {
describe("formatDurationCompact", () => {
it("returns undefined for null/undefined/non-positive", () => {
expect(formatDurationCompact(null)).toBeUndefined();
expect(formatDurationCompact(undefined)).toBeUndefined();
expect(formatDurationCompact(0)).toBeUndefined();
expect(formatDurationCompact(-100)).toBeUndefined();
it.each([null, undefined, 0, -100])("returns undefined for %j", (value) => {
expect(formatDurationCompact(value)).toBeUndefined();
});
it("formats compact units and omits trailing zero components", () => {
const cases = [
[500, "500ms"],
[999, "999ms"],
[1000, "1s"],
[45000, "45s"],
[59000, "59s"],
[60000, "1m"], // not "1m0s"
[65000, "1m5s"],
[90000, "1m30s"],
[3600000, "1h"], // not "1h0m"
[3660000, "1h1m"],
[5400000, "1h30m"],
[86400000, "1d"], // not "1d0h"
[90000000, "1d1h"],
[172800000, "2d"],
] as const;
for (const [input, expected] of cases) {
expect(formatDurationCompact(input), String(input)).toBe(expected);
}
expectFormatterCases(formatDurationCompact, [
{ input: 500, expected: "500ms" },
{ input: 999, expected: "999ms" },
{ input: 1000, expected: "1s" },
{ input: 45000, expected: "45s" },
{ input: 59000, expected: "59s" },
{ input: 60000, expected: "1m" },
{ input: 65000, expected: "1m5s" },
{ input: 90000, expected: "1m30s" },
{ input: 3600000, expected: "1h" },
{ input: 3660000, expected: "1h1m" },
{ input: 5400000, expected: "1h30m" },
{ input: 86400000, expected: "1d" },
{ input: 90000000, expected: "1d1h" },
{ input: 172800000, expected: "2d" },
]);
});
it("supports spaced option", () => {
expect(formatDurationCompact(65000, { spaced: true })).toBe("1m 5s");
expect(formatDurationCompact(3660000, { spaced: true })).toBe("1h 1m");
expect(formatDurationCompact(90000000, { spaced: true })).toBe("1d 1h");
});
it("rounds at boundaries", () => {
// 59.5 seconds rounds to 60s = 1m
expect(formatDurationCompact(59500)).toBe("1m");
// 59.4 seconds rounds to 59s
expect(formatDurationCompact(59400)).toBe("59s");
it.each([
{ input: 65000, options: { spaced: true }, expected: "1m 5s" },
{ input: 3660000, options: { spaced: true }, expected: "1h 1m" },
{ input: 90000000, options: { spaced: true }, expected: "1d 1h" },
{ input: 59500, expected: "1m" },
{ input: 59400, expected: "59s" },
])("formats compact duration for %j", ({ input, options, expected }) => {
expect(formatDurationCompact(input, options)).toBe(expected);
});
});
@@ -68,61 +68,47 @@ describe("format-duration", () => {
});
it("formats single-unit outputs and day threshold behavior", () => {
const cases = [
[500, "500ms"],
[5000, "5s"],
[180000, "3m"],
[7200000, "2h"],
[23 * 3600000, "23h"],
[24 * 3600000, "1d"],
[25 * 3600000, "1d"], // rounds
[172800000, "2d"],
] as const;
for (const [input, expected] of cases) {
expect(formatDurationHuman(input), String(input)).toBe(expected);
}
expectFormatterCases(formatDurationHuman, [
{ input: 500, expected: "500ms" },
{ input: 5000, expected: "5s" },
{ input: 180000, expected: "3m" },
{ input: 7200000, expected: "2h" },
{ input: 23 * 3600000, expected: "23h" },
{ input: 24 * 3600000, expected: "1d" },
{ input: 25 * 3600000, expected: "1d" },
{ input: 172800000, expected: "2d" },
]);
});
});
describe("formatDurationPrecise", () => {
it("shows milliseconds for sub-second", () => {
expect(formatDurationPrecise(500)).toBe("500ms");
expect(formatDurationPrecise(999)).toBe("999ms");
});
it("clamps negative and fractional sub-second values to non-negative milliseconds", () => {
expect(formatDurationPrecise(-1)).toBe("0ms");
expect(formatDurationPrecise(-500)).toBe("0ms");
expect(formatDurationPrecise(999.6)).toBe("1000ms");
});
it("shows decimal seconds for >=1s", () => {
expect(formatDurationPrecise(1000)).toBe("1s");
expect(formatDurationPrecise(1500)).toBe("1.5s");
expect(formatDurationPrecise(1234)).toBe("1.23s");
});
it("returns unknown for non-finite", () => {
expect(formatDurationPrecise(NaN)).toBe("unknown");
expect(formatDurationPrecise(Infinity)).toBe("unknown");
it.each([
{ input: 500, expected: "500ms" },
{ input: 999, expected: "999ms" },
{ input: -1, expected: "0ms" },
{ input: -500, expected: "0ms" },
{ input: 999.6, expected: "1000ms" },
{ input: 1000, expected: "1s" },
{ input: 1500, expected: "1.5s" },
{ input: 1234, expected: "1.23s" },
{ input: NaN, expected: "unknown" },
{ input: Infinity, expected: "unknown" },
])("formats precise duration for %j", ({ input, expected }) => {
expect(formatDurationPrecise(input)).toBe(expected);
});
});
describe("formatDurationSeconds", () => {
it("formats with configurable decimals", () => {
expect(formatDurationSeconds(1500, { decimals: 1 })).toBe("1.5s");
expect(formatDurationSeconds(1234, { decimals: 2 })).toBe("1.23s");
expect(formatDurationSeconds(1000, { decimals: 0 })).toBe("1s");
});
it("supports seconds unit", () => {
expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds");
});
it("clamps negative values and rejects non-finite input", () => {
expect(formatDurationSeconds(-1500, { decimals: 1 })).toBe("0s");
expect(formatDurationSeconds(NaN)).toBe("unknown");
expect(formatDurationSeconds(Infinity)).toBe("unknown");
it.each([
{ input: 1500, options: { decimals: 1 }, expected: "1.5s" },
{ input: 1234, options: { decimals: 2 }, expected: "1.23s" },
{ input: 1000, options: { decimals: 0 }, expected: "1s" },
{ input: 2000, options: { unit: "seconds" as const }, expected: "2 seconds" },
{ input: -1500, options: { decimals: 1 }, expected: "0s" },
{ input: NaN, options: undefined, expected: "unknown" },
{ input: Infinity, options: undefined, expected: "unknown" },
])("formats seconds duration for %j", ({ input, options, expected }) => {
expect(formatDurationSeconds(input, options)).toBe(expected);
});
});
});
@@ -222,25 +208,24 @@ describe("format-relative", () => {
});
it("formats relative age around key unit boundaries", () => {
const cases = [
[0, "just now"],
[29000, "just now"], // rounds to <1m
[30000, "1m ago"], // 30s rounds to 1m
[300000, "5m ago"],
[7200000, "2h ago"],
[47 * 3600000, "47h ago"],
[48 * 3600000, "2d ago"],
[172800000, "2d ago"],
] as const;
for (const [input, expected] of cases) {
expect(formatTimeAgo(input), String(input)).toBe(expected);
}
expectFormatterCases(formatTimeAgo, [
{ input: 0, expected: "just now" },
{ input: 29000, expected: "just now" },
{ input: 30000, expected: "1m ago" },
{ input: 300000, expected: "5m ago" },
{ input: 7200000, expected: "2h ago" },
{ input: 47 * 3600000, expected: "47h ago" },
{ input: 48 * 3600000, expected: "2d ago" },
{ input: 172800000, expected: "2d ago" },
]);
});
it("omits suffix when suffix: false", () => {
expect(formatTimeAgo(0, { suffix: false })).toBe("0s");
expect(formatTimeAgo(300000, { suffix: false })).toBe("5m");
expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h");
it.each([
{ input: 0, expected: "0s" },
{ input: 300000, expected: "5m" },
{ input: 7200000, expected: "2h" },
])("omits suffix for %j when disabled", ({ input, expected }) => {
expect(formatTimeAgo(input, { suffix: false })).toBe(expected);
});
});

View File

@@ -5,34 +5,21 @@ import {
} from "./parse-offsetless-zoned-datetime.js";
describe("parseOffsetlessIsoDateTimeInTimeZone", () => {
it("detects offset-less ISO datetimes", () => {
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00")).toBe(true);
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00+02:00")).toBe(false);
expect(isOffsetlessIsoDateTime("+20m")).toBe(false);
it.each([
["2026-03-23T23:00:00", true],
["2026-03-23T23:00:00+02:00", false],
["+20m", false],
])("detects offset-less ISO datetime %s", (input, expected) => {
expect(isOffsetlessIsoDateTime(input)).toBe(expected);
});
it("converts offset-less datetimes in the requested timezone", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Europe/Oslo")).toBe(
"2026-03-23T22:00:00.000Z",
);
});
it("keeps DST boundary conversions on the intended wall-clock time", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T01:30:00", "Europe/Oslo")).toBe(
"2026-03-29T00:30:00.000Z",
);
});
it("returns null for nonexistent DST gap wall-clock times", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T02:30:00", "Europe/Oslo")).toBe(null);
});
it("returns null for invalid input", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe(
null,
);
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Invalid/Timezone")).toBe(
null,
);
it.each([
["2026-03-23T23:00:00", "Europe/Oslo", "2026-03-23T22:00:00.000Z"],
["2026-03-29T01:30:00", "Europe/Oslo", "2026-03-29T00:30:00.000Z"],
["2026-03-29T02:30:00", "Europe/Oslo", null],
["2026-03-23T23:00:00+02:00", "Europe/Oslo", null],
["2026-03-23T23:00:00", "Invalid/Timezone", null],
])("parses zoned datetime %s in %s", (input, timezone, expected) => {
expect(parseOffsetlessIsoDateTimeInTimeZone(input, timezone)).toBe(expected);
});
});

View File

@@ -11,24 +11,15 @@ describe("parseGeminiAuth", () => {
});
});
it("falls back to API key auth for invalid or unusable OAuth payloads", () => {
expect(parseGeminiAuth('{"token":"","projectId":"demo"}')).toEqual({
headers: {
"x-goog-api-key": '{"token":"","projectId":"demo"}',
"Content-Type": "application/json",
},
});
expect(parseGeminiAuth("{not-json}")).toEqual({
headers: {
"x-goog-api-key": "{not-json}",
"Content-Type": "application/json",
},
});
expect(parseGeminiAuth(' {"token":"oauth-token"}')).toEqual({
headers: {
"x-goog-api-key": ' {"token":"oauth-token"}',
"Content-Type": "application/json",
},
});
});
it.each(['{"token":"","projectId":"demo"}', "{not-json}", ' {"token":"oauth-token"}'])(
"falls back to API key auth for %j",
(value) => {
expect(parseGeminiAuth(value)).toEqual({
headers: {
"x-goog-api-key": value,
"Content-Type": "application/json",
},
});
},
);
});

View File

@@ -6,27 +6,28 @@ describe("normalizeGoogleApiBaseUrl", () => {
expect(normalizeGoogleApiBaseUrl()).toBe(DEFAULT_GOOGLE_API_BASE_URL);
});
it("normalizes the bare Google API host to the Gemini v1beta root", () => {
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com")).toBe(
DEFAULT_GOOGLE_API_BASE_URL,
);
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/")).toBe(
DEFAULT_GOOGLE_API_BASE_URL,
);
});
it("preserves explicit Google API paths", () => {
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1beta")).toBe(
DEFAULT_GOOGLE_API_BASE_URL,
);
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1")).toBe(
"https://generativelanguage.googleapis.com/v1",
);
});
it("preserves custom proxy paths", () => {
expect(normalizeGoogleApiBaseUrl("https://proxy.example.com/google/v1beta/")).toBe(
"https://proxy.example.com/google/v1beta",
);
it.each([
{
value: "https://generativelanguage.googleapis.com",
expected: DEFAULT_GOOGLE_API_BASE_URL,
},
{
value: "https://generativelanguage.googleapis.com/",
expected: DEFAULT_GOOGLE_API_BASE_URL,
},
{
value: "https://generativelanguage.googleapis.com/v1beta",
expected: DEFAULT_GOOGLE_API_BASE_URL,
},
{
value: "https://generativelanguage.googleapis.com/v1",
expected: "https://generativelanguage.googleapis.com/v1",
},
{
value: "https://proxy.example.com/google/v1beta/",
expected: "https://proxy.example.com/google/v1beta",
},
])("normalizes %s", ({ value, expected }) => {
expect(normalizeGoogleApiBaseUrl(value)).toBe(expected);
});
});

View File

@@ -5,50 +5,52 @@ import { describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js";
async function withHardlinkFixture(
cb: (context: { root: string; source: string; linked: string; dirPath: string }) => Promise<void>,
): Promise<void> {
await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => {
const dirPath = path.join(root, "dir");
const source = path.join(root, "source.txt");
const linked = path.join(root, "linked.txt");
await fs.mkdir(dirPath);
await fs.writeFile(source, "hello", "utf8");
await fs.link(source, linked);
await cb({ root, source, linked, dirPath });
});
}
describe("assertNoHardlinkedFinalPath", () => {
it("allows missing paths, directories, and explicit unlink opt-in", async () => {
await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => {
const dirPath = path.join(root, "dir");
await fs.mkdir(dirPath);
it.each([
{
name: "allows missing paths",
filePath: ({ root }: { root: string }) => path.join(root, "missing.txt"),
opts: {},
},
{
name: "allows directories",
filePath: ({ dirPath }: { dirPath: string }) => dirPath,
opts: {},
},
{
name: "allows explicit unlink opt-in",
filePath: ({ linked }: { linked: string }) => linked,
opts: { allowFinalHardlinkForUnlink: true },
},
])("$name", async ({ filePath, opts }) => {
await withHardlinkFixture(async (context) => {
await expect(
assertNoHardlinkedFinalPath({
filePath: path.join(root, "missing.txt"),
root,
filePath: filePath(context),
root: context.root,
boundaryLabel: "workspace",
}),
).resolves.toBeUndefined();
await expect(
assertNoHardlinkedFinalPath({
filePath: dirPath,
root,
boundaryLabel: "workspace",
}),
).resolves.toBeUndefined();
const source = path.join(root, "source.txt");
const linked = path.join(root, "linked.txt");
await fs.writeFile(source, "hello", "utf8");
await fs.link(source, linked);
await expect(
assertNoHardlinkedFinalPath({
filePath: linked,
root,
boundaryLabel: "workspace",
allowFinalHardlinkForUnlink: true,
...opts,
}),
).resolves.toBeUndefined();
});
});
it("rejects hardlinked files and shortens home-relative paths in the error", async () => {
await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => {
const source = path.join(root, "source.txt");
const linked = path.join(root, "linked.txt");
await fs.writeFile(source, "hello", "utf8");
await fs.link(source, linked);
await withHardlinkFixture(async ({ root, linked }) => {
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root);
const expectedLinkedPath = path.join("~", "linked.txt");

View File

@@ -84,6 +84,16 @@ async function expectPackFallsBackToDetectedArchive(params: {
});
}
function expectPackError(result: { ok: boolean; error?: string }, expected: string[]): void {
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
for (const part of expected) {
expect(result.error ?? "").toContain(part);
}
}
beforeEach(() => {
runCommandWithTimeoutMock.mockClear();
});
@@ -116,25 +126,25 @@ describe("withTempDir", () => {
});
describe("resolveArchiveSourcePath", () => {
it("returns not found error for missing archive paths", async () => {
const result = await resolveArchiveSourcePath("/tmp/does-not-exist-openclaw-archive.tgz");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("archive not found");
}
});
it("rejects unsupported archive extensions", async () => {
const { filePath } = await createFixtureFile({
fileName: "plugin.txt",
contents: "not-an-archive",
});
const result = await resolveArchiveSourcePath(filePath);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("unsupported archive");
}
it.each([
{
name: "returns not found error for missing archive paths",
path: async () => "/tmp/does-not-exist-openclaw-archive.tgz",
expected: "archive not found",
},
{
name: "rejects unsupported archive extensions",
path: async () =>
(
await createFixtureFile({
fileName: "plugin.txt",
contents: "not-an-archive",
})
).filePath,
expected: "unsupported archive",
},
])("$name", async ({ path: resolvePath, expected }) => {
expectPackError(await resolveArchiveSourcePath(await resolvePath()), [expected]);
});
it.each(["plugin.zip", "plugin.tgz", "plugin.tar.gz"])(
@@ -217,12 +227,7 @@ describe("packNpmSpecToArchive", () => {
});
const result = await runPack("bad-spec", cwd, 5000);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("npm pack failed");
expect(result.error).toContain("registry timeout");
}
expectPackError(result, ["npm pack failed", "registry timeout"]);
});
it.each([
@@ -259,13 +264,11 @@ describe("packNpmSpecToArchive", () => {
});
const result = await runPack("@openclaw/whatsapp", cwd);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("Package not found on npm");
expect(result.error).toContain("@openclaw/whatsapp");
expect(result.error).toContain("docs.openclaw.ai/tools/plugin");
}
expectPackError(result, [
"Package not found on npm",
"@openclaw/whatsapp",
"docs.openclaw.ai/tools/plugin",
]);
});
it("returns explicit error when npm pack produces no archive name", async () => {

View File

@@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { loadJsonFile, saveJsonFile } from "./json-file.js";
async function withJsonPath<T>(
run: (params: { root: string; pathname: string }) => Promise<T> | T,
): Promise<T> {
return withTempDir({ prefix: "openclaw-json-file-" }, async (root) =>
run({ root, pathname: path.join(root, "config.json") }),
);
}
describe("json-file helpers", () => {
it.each([
{
@@ -23,8 +31,7 @@ describe("json-file helpers", () => {
},
},
])("returns undefined for $name", async ({ setup }) => {
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
const pathname = path.join(root, "config.json");
await withJsonPath(({ pathname }) => {
setup(pathname);
expect(loadJsonFile(pathname)).toBeUndefined();
});
@@ -62,8 +69,7 @@ describe("json-file helpers", () => {
},
},
])("writes the latest payload for $name", async ({ setup }) => {
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
const pathname = path.join(root, "config.json");
await withJsonPath(({ pathname }) => {
setup(pathname);
saveJsonFile(pathname, { enabled: true, count: 2 });
expect(loadJsonFile(pathname)).toEqual({ enabled: true, count: 2 });

View File

@@ -5,56 +5,95 @@ import { setTimeout as sleep } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { createAsyncLock, readJsonFile, writeJsonAtomic, writeTextAtomic } from "./json-files.js";
async function withTempBase<T>(run: (base: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
return run(base);
}
describe("json file helpers", () => {
it("reads valid json and returns null for missing or invalid files", async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
const validPath = path.join(base, "valid.json");
const invalidPath = path.join(base, "invalid.json");
await fs.writeFile(validPath, '{"ok":true}', "utf8");
await fs.writeFile(invalidPath, "{not-json}", "utf8");
await expect(readJsonFile<{ ok: boolean }>(validPath)).resolves.toEqual({ ok: true });
await expect(readJsonFile(invalidPath)).resolves.toBeNull();
await expect(readJsonFile(path.join(base, "missing.json"))).resolves.toBeNull();
it.each([
{
name: "reads valid json",
setup: async (base: string) => {
const filePath = path.join(base, "valid.json");
await fs.writeFile(filePath, '{"ok":true}', "utf8");
return filePath;
},
expected: { ok: true },
},
{
name: "returns null for invalid files",
setup: async (base: string) => {
const filePath = path.join(base, "invalid.json");
await fs.writeFile(filePath, "{not-json}", "utf8");
return filePath;
},
expected: null,
},
{
name: "returns null for missing files",
setup: async (base: string) => path.join(base, "missing.json"),
expected: null,
},
])("$name", async ({ setup, expected }) => {
await withTempBase(async (base) => {
await expect(readJsonFile(await setup(base))).resolves.toEqual(expected);
});
});
it("writes json atomically with pretty formatting and optional trailing newline", async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
const filePath = path.join(base, "nested", "config.json");
await withTempBase(async (base) => {
const filePath = path.join(base, "nested", "config.json");
await writeJsonAtomic(
filePath,
{ ok: true, nested: { value: 1 } },
{ trailingNewline: true, ensureDirMode: 0o755 },
);
await writeJsonAtomic(
filePath,
{ ok: true, nested: { value: 1 } },
{ trailingNewline: true, ensureDirMode: 0o755 },
);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe(
'{\n "ok": true,\n "nested": {\n "value": 1\n }\n}\n',
);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe(
'{\n "ok": true,\n "nested": {\n "value": 1\n }\n}\n',
);
});
});
it("writes text atomically and avoids duplicate trailing newlines", async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
const filePath = path.join(base, "nested", "note.txt");
await writeTextAtomic(filePath, "hello", { appendTrailingNewline: true });
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("hello\n");
await writeTextAtomic(filePath, "hello\n", { appendTrailingNewline: true });
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("hello\n");
it.each([
{ input: "hello", expected: "hello\n" },
{ input: "hello\n", expected: "hello\n" },
])("writes text atomically for %j", async ({ input, expected }) => {
await withTempBase(async (base) => {
const filePath = path.join(base, "nested", "note.txt");
await writeTextAtomic(filePath, input, { appendTrailingNewline: true });
await expect(fs.readFile(filePath, "utf8")).resolves.toBe(expected);
});
});
it("serializes async lock callers even across rejections", async () => {
it.each([
{
name: "serializes async lock callers even across rejections",
firstTask: async (events: string[]) => {
events.push("first:start");
await sleep(20);
events.push("first:end");
throw new Error("boom");
},
expectedFirstError: "boom",
expectedEvents: ["first:start", "first:end", "second:start", "second:end"],
},
{
name: "releases the async lock after synchronous throws",
firstTask: async (events: string[]) => {
events.push("first:start");
throw new Error("sync boom");
},
expectedFirstError: "sync boom",
expectedEvents: ["first:start", "second:start", "second:end"],
},
])("$name", async ({ firstTask, expectedFirstError, expectedEvents }) => {
const withLock = createAsyncLock();
const events: string[] = [];
const first = withLock(async () => {
events.push("first:start");
await sleep(20);
events.push("first:end");
throw new Error("boom");
});
const first = withLock(() => firstTask(events));
const second = withLock(async () => {
events.push("second:start");
@@ -62,28 +101,8 @@ describe("json file helpers", () => {
return "ok";
});
await expect(first).rejects.toThrow("boom");
await expect(first).rejects.toThrow(expectedFirstError);
await expect(second).resolves.toBe("ok");
expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]);
});
it("releases the async lock after synchronous throws", async () => {
const withLock = createAsyncLock();
const events: string[] = [];
const first = withLock(async () => {
events.push("first:start");
throw new Error("sync boom");
});
const second = withLock(async () => {
events.push("second:start");
events.push("second:end");
return "ok";
});
await expect(first).rejects.toThrow("sync boom");
await expect(second).resolves.toBe("ok");
expect(events).toEqual(["first:start", "second:start", "second:end"]);
expect(events).toEqual(expectedEvents);
});
});

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { jsonUtf8Bytes } from "./json-utf8-bytes.js";
function createCircularValue() {
const circular: { self?: unknown } = {};
circular.self = circular;
return circular;
}
describe("jsonUtf8Bytes", () => {
it.each([
{
@@ -27,13 +33,12 @@ describe("jsonUtf8Bytes", () => {
expect(jsonUtf8Bytes(value)).toBe(expected);
});
it("falls back to string conversion when JSON serialization throws", () => {
const circular: { self?: unknown } = {};
circular.self = circular;
expect(jsonUtf8Bytes(circular)).toBe(Buffer.byteLength("[object Object]", "utf8"));
});
it.each([
{
name: "circular serialization failures",
value: createCircularValue(),
expected: "[object Object]",
},
{ name: "BigInt serialization failures", value: 12n, expected: "12" },
{ name: "symbol serialization failures", value: Symbol("token"), expected: "Symbol(token)" },
])("uses string conversion for $name", ({ value, expected }) => {

View File

@@ -28,61 +28,68 @@ describe("hasProxyEnvConfigured", () => {
});
describe("resolveEnvHttpProxyUrl", () => {
it("uses lower-case https_proxy before upper-case HTTPS_PROXY", () => {
const env = {
https_proxy: "http://lower.test:8080",
HTTPS_PROXY: "http://upper.test:8080",
} as NodeJS.ProcessEnv;
expect(resolveEnvHttpProxyUrl("https", env)).toBe("http://lower.test:8080");
});
it("treats empty lower-case https_proxy as authoritative over upper-case HTTPS_PROXY", () => {
const env = {
https_proxy: "",
HTTPS_PROXY: "http://upper.test:8080",
} as NodeJS.ProcessEnv;
expect(resolveEnvHttpProxyUrl("https", env)).toBeUndefined();
expect(hasEnvHttpProxyConfigured("https", env)).toBe(false);
});
it("treats empty lower-case http_proxy as authoritative over upper-case HTTP_PROXY", () => {
const env = {
http_proxy: " ",
HTTP_PROXY: "http://upper-http.test:8080",
} as NodeJS.ProcessEnv;
expect(resolveEnvHttpProxyUrl("http", env)).toBeUndefined();
expect(hasEnvHttpProxyConfigured("http", env)).toBe(false);
});
it("falls back from HTTPS proxy vars to HTTP proxy vars for https requests", () => {
const env = {
HTTP_PROXY: "http://upper-http.test:8080",
} as NodeJS.ProcessEnv;
expect(resolveEnvHttpProxyUrl("https", env)).toBe("http://upper-http.test:8080");
expect(hasEnvHttpProxyConfigured("https", env)).toBe(true);
});
it("does not use ALL_PROXY for EnvHttpProxyAgent-style resolution", () => {
const env = {
ALL_PROXY: "http://all-proxy.test:8080",
all_proxy: "http://lower-all-proxy.test:8080",
} as NodeJS.ProcessEnv;
expect(resolveEnvHttpProxyUrl("https", env)).toBeUndefined();
expect(resolveEnvHttpProxyUrl("http", env)).toBeUndefined();
expect(hasEnvHttpProxyConfigured("https", env)).toBe(false);
});
it("returns only HTTP proxies for http requests", () => {
const env = {
https_proxy: "http://lower-https.test:8080",
http_proxy: "http://lower-http.test:8080",
} as NodeJS.ProcessEnv;
expect(resolveEnvHttpProxyUrl("http", env)).toBe("http://lower-http.test:8080");
it.each([
{
name: "uses lower-case https_proxy before upper-case HTTPS_PROXY",
protocol: "https" as const,
env: {
https_proxy: "http://lower.test:8080",
HTTPS_PROXY: "http://upper.test:8080",
} as NodeJS.ProcessEnv,
expectedUrl: "http://lower.test:8080",
expectedConfigured: true,
},
{
name: "treats empty lower-case https_proxy as authoritative over upper-case HTTPS_PROXY",
protocol: "https" as const,
env: {
https_proxy: "",
HTTPS_PROXY: "http://upper.test:8080",
} as NodeJS.ProcessEnv,
expectedUrl: undefined,
expectedConfigured: false,
},
{
name: "treats empty lower-case http_proxy as authoritative over upper-case HTTP_PROXY",
protocol: "http" as const,
env: {
http_proxy: " ",
HTTP_PROXY: "http://upper-http.test:8080",
} as NodeJS.ProcessEnv,
expectedUrl: undefined,
expectedConfigured: false,
},
{
name: "falls back from HTTPS proxy vars to HTTP proxy vars for https requests",
protocol: "https" as const,
env: {
HTTP_PROXY: "http://upper-http.test:8080",
} as NodeJS.ProcessEnv,
expectedUrl: "http://upper-http.test:8080",
expectedConfigured: true,
},
{
name: "does not use ALL_PROXY for EnvHttpProxyAgent-style resolution",
protocol: "https" as const,
env: {
ALL_PROXY: "http://all-proxy.test:8080",
all_proxy: "http://lower-all-proxy.test:8080",
} as NodeJS.ProcessEnv,
expectedUrl: undefined,
expectedConfigured: false,
},
{
name: "returns only HTTP proxies for http requests",
protocol: "http" as const,
env: {
https_proxy: "http://lower-https.test:8080",
http_proxy: "http://lower-http.test:8080",
} as NodeJS.ProcessEnv,
expectedUrl: "http://lower-http.test:8080",
expectedConfigured: true,
},
])("$name", ({ protocol, env, expectedUrl, expectedConfigured }) => {
expect(resolveEnvHttpProxyUrl(protocol, env)).toBe(expectedUrl);
expect(hasEnvHttpProxyConfigured(protocol, env)).toBe(expectedConfigured);
});
});

View File

@@ -83,24 +83,26 @@ const unsupportedLegacyIpv4Cases = [
const nonIpHostnameCases = ["example.com", "abc.123.example", "1password.com", "0x.example.com"];
function expectIpPrivacyCases(cases: string[], expected: boolean) {
for (const address of cases) {
expect(isPrivateIpAddress(address)).toBe(expected);
}
}
describe("ssrf ip classification", () => {
it("classifies blocked ip literals as private", () => {
const blockedCases = [...privateIpCases, ...malformedIpv6Cases, ...unsupportedLegacyIpv4Cases];
for (const address of blockedCases) {
expect(isPrivateIpAddress(address)).toBe(true);
}
expectIpPrivacyCases(
[...privateIpCases, ...malformedIpv6Cases, ...unsupportedLegacyIpv4Cases],
true,
);
});
it("classifies public ip literals as non-private", () => {
for (const address of publicIpCases) {
expect(isPrivateIpAddress(address)).toBe(false);
}
expectIpPrivacyCases(publicIpCases, false);
});
it("does not treat hostnames as ip literals", () => {
for (const hostname of nonIpHostnameCases) {
expect(isPrivateIpAddress(hostname)).toBe(false);
}
expectIpPrivacyCases(nonIpHostnameCases, false);
});
});
@@ -127,12 +129,13 @@ describe("isBlockedHostnameOrIp", () => {
expect(isBlockedHostnameOrIp(value)).toBe(expected);
});
it("supports opt-in policy to allow RFC2544 benchmark range", () => {
const policy = { allowRfc2544BenchmarkRange: true };
expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true);
expect(isBlockedHostnameOrIp("198.18.0.1", policy)).toBe(false);
expect(isBlockedHostnameOrIp("::ffff:198.18.0.1", policy)).toBe(false);
expect(isBlockedHostnameOrIp("198.51.100.1", policy)).toBe(true);
it.each([
["198.18.0.1", undefined, true],
["198.18.0.1", { allowRfc2544BenchmarkRange: true }, false],
["::ffff:198.18.0.1", { allowRfc2544BenchmarkRange: true }, false],
["198.51.100.1", { allowRfc2544BenchmarkRange: true }, true],
] as const)("applies RFC2544 benchmark policy for %s", (value, policy, expected) => {
expect(isBlockedHostnameOrIp(value, policy)).toBe(expected);
});
it.each(["0177.0.0.1", "8.8.2056", "127.1", "2130706433"])(
@@ -142,8 +145,7 @@ describe("isBlockedHostnameOrIp", () => {
},
);
it("does not block ordinary hostnames", () => {
expect(isBlockedHostnameOrIp("example.com")).toBe(false);
expect(isBlockedHostnameOrIp("api.example.net")).toBe(false);
it.each(["example.com", "api.example.net"])("does not block ordinary hostname %s", (value) => {
expect(isBlockedHostnameOrIp(value)).toBe(false);
});
});

View File

@@ -7,23 +7,27 @@ import {
resolveBestEffortGatewayBindHostForDisplay,
} from "./network-discovery-display.js";
const discoveryErrorMessage = "uv_interface_addresses failed";
function mockInterfaceDiscoveryFailure(): void {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error(discoveryErrorMessage);
});
}
describe("network display discovery", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns no LAN address when interface discovery throws", () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
mockInterfaceDiscoveryFailure();
expect(pickBestEffortPrimaryLanIPv4()).toBeUndefined();
});
it("reports a warning when tailnet inspection throws", () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
mockInterfaceDiscoveryFailure();
expect(
inspectBestEffortPrimaryTailnetIPv4({
@@ -31,14 +35,12 @@ describe("network display discovery", () => {
}),
).toEqual({
tailnetIPv4: undefined,
warning: "Status could not inspect tailnet addresses: uv_interface_addresses failed.",
warning: `Status could not inspect tailnet addresses: ${discoveryErrorMessage}.`,
});
});
it("falls back to loopback when bind host resolution throws", async () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
mockInterfaceDiscoveryFailure();
await expect(
resolveBestEffortGatewayBindHostForDisplay({
@@ -48,8 +50,7 @@ describe("network display discovery", () => {
}),
).resolves.toEqual({
bindHost: "127.0.0.1",
warning:
"Status is using fallback network details because interface discovery failed: uv_interface_addresses failed.",
warning: `Status is using fallback network details because interface discovery failed: ${discoveryErrorMessage}.`,
});
});

View File

@@ -15,33 +15,38 @@ describe("network-interfaces", () => {
).toBeUndefined();
});
it("lists trimmed non-internal external addresses only", () => {
const snapshot = makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [
{ address: " 192.168.1.42 ", family: "IPv4" },
{ address: "fd7a:115c:a1e0::1", family: "IPv6" },
{ address: " ", family: "IPv6" },
],
});
expect(listExternalInterfaceAddresses(snapshot)).toEqual([
{ name: "en0", address: "192.168.1.42", family: "IPv4" },
{ name: "en0", address: "fd7a:115c:a1e0::1", family: "IPv6" },
]);
});
it("prefers configured interface names before falling back", () => {
const snapshot = makeNetworkInterfacesSnapshot({
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
en0: [{ address: "192.168.1.42", family: "IPv4" }],
});
expect(
pickMatchingExternalInterfaceAddress(snapshot, {
family: "IPv4",
preferredNames: ["en0", "eth0"],
it.each([
{
name: "lists trimmed non-internal external addresses only",
snapshot: makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [
{ address: " 192.168.1.42 ", family: "IPv4" },
{ address: "fd7a:115c:a1e0::1", family: "IPv6" },
{ address: " ", family: "IPv6" },
],
}),
).toBe("192.168.1.42");
run: (snapshot: ReturnType<typeof makeNetworkInterfacesSnapshot>) =>
expect(listExternalInterfaceAddresses(snapshot)).toEqual([
{ name: "en0", address: "192.168.1.42", family: "IPv4" },
{ name: "en0", address: "fd7a:115c:a1e0::1", family: "IPv6" },
]),
},
{
name: "prefers configured interface names before falling back",
snapshot: makeNetworkInterfacesSnapshot({
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
en0: [{ address: "192.168.1.42", family: "IPv4" }],
}),
run: (snapshot: ReturnType<typeof makeNetworkInterfacesSnapshot>) =>
expect(
pickMatchingExternalInterfaceAddress(snapshot, {
family: "IPv4",
preferredNames: ["en0", "eth0"],
}),
).toBe("192.168.1.42"),
},
])("$name", ({ snapshot, run }) => {
run(snapshot);
});
});

View File

@@ -8,150 +8,161 @@ import {
validateRegistryNpmSpec,
} from "./npm-registry-spec.js";
function parseSpecOrThrow(spec: string) {
const parsed = parseRegistryNpmSpec(spec);
expect(parsed).not.toBeNull();
return parsed!;
}
describe("npm registry spec validation", () => {
it("accepts bare package names, exact versions, and dist-tags", () => {
expect(validateRegistryNpmSpec("@openclaw/voice-call")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.4")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@latest")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@beta")).toBeNull();
it.each([
"@openclaw/voice-call",
"@openclaw/voice-call@1.2.3",
"@openclaw/voice-call@1.2.3-beta.4",
"@openclaw/voice-call@latest",
"@openclaw/voice-call@beta",
])("accepts %s", (spec) => {
expect(validateRegistryNpmSpec(spec)).toBeNull();
});
it("rejects semver ranges", () => {
expect(validateRegistryNpmSpec("@openclaw/voice-call@^1.2.3")).toContain(
"exact version or dist-tag",
);
expect(validateRegistryNpmSpec("@openclaw/voice-call@~1.2.3")).toContain(
"exact version or dist-tag",
);
});
it("rejects unsupported registry protocols and malformed selectors", () => {
expect(validateRegistryNpmSpec("https://npmjs.org/pkg.tgz")).toContain("URLs are not allowed");
expect(validateRegistryNpmSpec("git+ssh://github.com/openclaw/openclaw")).toContain(
"URLs are not allowed",
);
expect(validateRegistryNpmSpec("@openclaw/voice-call@")).toContain(
"missing version/tag after @",
);
expect(validateRegistryNpmSpec("@openclaw/voice-call@../beta")).toContain(
"invalid version/tag",
);
it.each([
{
spec: "@openclaw/voice-call@^1.2.3",
expected: "exact version or dist-tag",
},
{
spec: "@openclaw/voice-call@~1.2.3",
expected: "exact version or dist-tag",
},
{
spec: "https://npmjs.org/pkg.tgz",
expected: "URLs are not allowed",
},
{
spec: "git+ssh://github.com/openclaw/openclaw",
expected: "URLs are not allowed",
},
{
spec: "@openclaw/voice-call@",
expected: "missing version/tag after @",
},
{
spec: "@openclaw/voice-call@../beta",
expected: "invalid version/tag",
},
])("rejects %s", ({ spec, expected }) => {
expect(validateRegistryNpmSpec(spec)).toContain(expected);
});
});
describe("npm registry spec parsing helpers", () => {
it("parses bare, tag, and exact prerelease specs", () => {
expect(parseRegistryNpmSpec("@openclaw/voice-call")).toEqual({
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call",
selectorKind: "none",
selectorIsPrerelease: false,
});
expect(parseRegistryNpmSpec("@openclaw/voice-call@beta")).toEqual({
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call@beta",
selector: "beta",
selectorKind: "tag",
selectorIsPrerelease: false,
});
expect(parseRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.1")).toEqual({
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call@1.2.3-beta.1",
selector: "1.2.3-beta.1",
selectorKind: "exact-version",
selectorIsPrerelease: true,
});
it.each([
{
spec: "@openclaw/voice-call",
expected: {
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call",
selectorKind: "none",
selectorIsPrerelease: false,
},
},
{
spec: "@openclaw/voice-call@beta",
expected: {
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call@beta",
selector: "beta",
selectorKind: "tag",
selectorIsPrerelease: false,
},
},
{
spec: "@openclaw/voice-call@1.2.3-beta.1",
expected: {
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call@1.2.3-beta.1",
selector: "1.2.3-beta.1",
selectorKind: "exact-version",
selectorIsPrerelease: true,
},
},
])("parses %s", ({ spec, expected }) => {
expect(parseRegistryNpmSpec(spec)).toEqual(expected);
});
it("detects exact and prerelease semver versions", () => {
expect(isExactSemverVersion("v1.2.3")).toBe(true);
expect(isExactSemverVersion("1.2")).toBe(false);
expect(isPrereleaseSemverVersion("1.2.3-beta.1")).toBe(true);
expect(isPrereleaseSemverVersion("1.2.3")).toBe(false);
it.each([
{ value: "v1.2.3", expected: true },
{ value: "1.2", expected: false },
])("detects exact semver versions for %s", ({ value, expected }) => {
expect(isExactSemverVersion(value)).toBe(expected);
});
it.each([
{ value: "1.2.3-beta.1", expected: true },
{ value: "1.2.3", expected: false },
])("detects prerelease semver versions for %s", ({ value, expected }) => {
expect(isPrereleaseSemverVersion(value)).toBe(expected);
});
});
describe("npm prerelease resolution policy", () => {
it("blocks prerelease resolutions for bare specs", () => {
const spec = parseRegistryNpmSpec("@openclaw/voice-call");
expect(spec).not.toBeNull();
it.each([
{
spec: "@openclaw/voice-call",
resolvedVersion: "1.2.3-beta.1",
expected: false,
},
{
spec: "@openclaw/voice-call@latest",
resolvedVersion: "1.2.3-rc.1",
expected: false,
},
{
spec: "@openclaw/voice-call@beta",
resolvedVersion: "1.2.3-beta.4",
expected: true,
},
{
spec: "@openclaw/voice-call@1.2.3-beta.1",
resolvedVersion: "1.2.3-beta.1",
expected: true,
},
{
spec: "@openclaw/voice-call",
resolvedVersion: "1.2.3",
expected: true,
},
{
spec: "@openclaw/voice-call@latest",
resolvedVersion: undefined,
expected: true,
},
])("decides prerelease resolution for %s -> %s", ({ spec, resolvedVersion, expected }) => {
expect(
isPrereleaseResolutionAllowed({
spec: spec!,
resolvedVersion: "1.2.3-beta.1",
spec: parseSpecOrThrow(spec),
resolvedVersion,
}),
).toBe(false);
).toBe(expected);
});
it("blocks prerelease resolutions for latest", () => {
const spec = parseRegistryNpmSpec("@openclaw/voice-call@latest");
expect(spec).not.toBeNull();
expect(
isPrereleaseResolutionAllowed({
spec: spec!,
resolvedVersion: "1.2.3-rc.1",
}),
).toBe(false);
});
it("allows prerelease resolutions when the user explicitly opted in", () => {
const tagSpec = parseRegistryNpmSpec("@openclaw/voice-call@beta");
const versionSpec = parseRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.1");
expect(tagSpec).not.toBeNull();
expect(versionSpec).not.toBeNull();
expect(
isPrereleaseResolutionAllowed({
spec: tagSpec!,
resolvedVersion: "1.2.3-beta.4",
}),
).toBe(true);
expect(
isPrereleaseResolutionAllowed({
spec: versionSpec!,
resolvedVersion: "1.2.3-beta.1",
}),
).toBe(true);
});
it("allows stable resolutions even for bare and latest specs", () => {
const bareSpec = parseRegistryNpmSpec("@openclaw/voice-call");
const latestSpec = parseRegistryNpmSpec("@openclaw/voice-call@latest");
expect(bareSpec).not.toBeNull();
expect(latestSpec).not.toBeNull();
expect(
isPrereleaseResolutionAllowed({
spec: bareSpec!,
resolvedVersion: "1.2.3",
}),
).toBe(true);
expect(
isPrereleaseResolutionAllowed({
spec: latestSpec!,
resolvedVersion: undefined,
}),
).toBe(true);
});
it("formats prerelease resolution guidance based on selector intent", () => {
const bareSpec = parseRegistryNpmSpec("@openclaw/voice-call");
const tagSpec = parseRegistryNpmSpec("@openclaw/voice-call@beta");
expect(bareSpec).not.toBeNull();
expect(tagSpec).not.toBeNull();
it.each([
{
spec: "@openclaw/voice-call",
resolvedVersion: "1.2.3-beta.1",
expected: `Use "@openclaw/voice-call@beta"`,
},
{
spec: "@openclaw/voice-call@beta",
resolvedVersion: "1.2.3-rc.1",
expected: "Use an explicit prerelease tag or exact prerelease version",
},
])("formats prerelease guidance for %s", ({ spec, resolvedVersion, expected }) => {
expect(
formatPrereleaseResolutionError({
spec: bareSpec!,
resolvedVersion: "1.2.3-beta.1",
spec: parseSpecOrThrow(spec),
resolvedVersion,
}),
).toContain(`Use "@openclaw/voice-call@beta"`);
expect(
formatPrereleaseResolutionError({
spec: tagSpec!,
resolvedVersion: "1.2.3-rc.1",
}),
).toContain("Use an explicit prerelease tag or exact prerelease version");
).toContain(expected);
});
});

View File

@@ -21,9 +21,16 @@ describe("markOpenClawExecEnv", () => {
});
describe("ensureOpenClawExecMarkerOnProcess", () => {
it("mutates and returns the provided process env", () => {
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
it.each([
{
name: "mutates and returns the provided process env",
env: { PATH: "/usr/bin" } as NodeJS.ProcessEnv,
},
{
name: "overwrites an existing marker on the provided process env",
env: { PATH: "/usr/bin", [OPENCLAW_CLI_ENV_VAR]: "0" } as NodeJS.ProcessEnv,
},
])("$name", ({ env }) => {
expect(ensureOpenClawExecMarkerOnProcess(env)).toBe(env);
expect(env[OPENCLAW_CLI_ENV_VAR]).toBe(OPENCLAW_CLI_ENV_VALUE);
});

View File

@@ -6,59 +6,66 @@ import {
parseStrictPositiveInteger,
} from "./parse-finite-number.js";
describe("parseFiniteNumber", () => {
it.each([
{ value: 42, expected: 42 },
{ value: "3.14", expected: 3.14 },
{ value: " 3.14ms", expected: 3.14 },
{ value: "+7", expected: 7 },
{ value: "1e3", expected: 1000 },
])("parses %j", ({ value, expected }) => {
expect(parseFiniteNumber(value)).toBe(expected);
});
function expectParserCases<T>(
parse: (value: unknown) => T | undefined,
cases: Array<{ value: unknown; expected: T | undefined }>,
) {
for (const { value, expected } of cases) {
expect(parse(value)).toBe(expected);
}
}
it.each([Number.NaN, Number.POSITIVE_INFINITY, "not-a-number", " ", "", null])(
"returns undefined for %j",
(value) => {
expect(parseFiniteNumber(value)).toBeUndefined();
},
);
describe("parseFiniteNumber", () => {
it("parses finite values and rejects invalid inputs", () => {
expectParserCases(parseFiniteNumber, [
{ value: 42, expected: 42 },
{ value: "3.14", expected: 3.14 },
{ value: " 3.14ms", expected: 3.14 },
{ value: "+7", expected: 7 },
{ value: "1e3", expected: 1000 },
{ value: Number.NaN, expected: undefined },
{ value: Number.POSITIVE_INFINITY, expected: undefined },
{ value: "not-a-number", expected: undefined },
{ value: " ", expected: undefined },
{ value: "", expected: undefined },
{ value: null, expected: undefined },
]);
});
});
describe("parseStrictInteger", () => {
it.each([
{ value: "42", expected: 42 },
{ value: " -7 ", expected: -7 },
{ value: 12, expected: 12 },
{ value: "+9", expected: 9 },
])("parses %j", ({ value, expected }) => {
expect(parseStrictInteger(value)).toBe(expected);
it("parses strict integers and rejects non-integers", () => {
expectParserCases(parseStrictInteger, [
{ value: "42", expected: 42 },
{ value: " -7 ", expected: -7 },
{ value: 12, expected: 12 },
{ value: "+9", expected: 9 },
{ value: "42ms", expected: undefined },
{ value: "0abc", expected: undefined },
{ value: "1.5", expected: undefined },
{ value: "1e3", expected: undefined },
{ value: " ", expected: undefined },
{ value: Number.MAX_SAFE_INTEGER + 1, expected: undefined },
]);
});
it.each(["42ms", "0abc", "1.5", "1e3", " ", Number.MAX_SAFE_INTEGER + 1])(
"rejects %j",
(value) => {
expect(parseStrictInteger(value)).toBeUndefined();
},
);
});
describe("parseStrictPositiveInteger", () => {
it.each([
{ value: "9", expected: 9 },
{ value: "0", expected: undefined },
{ value: "-1", expected: undefined },
])("parses %j", ({ value, expected }) => {
expect(parseStrictPositiveInteger(value)).toBe(expected);
it("enforces positive integers", () => {
expectParserCases(parseStrictPositiveInteger, [
{ value: "9", expected: 9 },
{ value: "0", expected: undefined },
{ value: "-1", expected: undefined },
]);
});
});
describe("parseStrictNonNegativeInteger", () => {
it.each([
{ value: "0", expected: 0 },
{ value: "9", expected: 9 },
{ value: "-1", expected: undefined },
])("parses %j", ({ value, expected }) => {
expect(parseStrictNonNegativeInteger(value)).toBe(expected);
it("allows zero and positive integers only", () => {
expectParserCases(parseStrictNonNegativeInteger, [
{ value: "0", expected: 0 },
{ value: "9", expected: 9 },
{ value: "-1", expected: undefined },
]);
});
});

View File

@@ -4,72 +4,60 @@ import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { assertNoPathAliasEscape } from "./path-alias-guards.js";
async function withAliasRoot(cb: (root: string) => Promise<void>): Promise<void> {
await withTempDir(
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
cb,
);
}
describe("assertNoPathAliasEscape", () => {
it.runIf(process.platform !== "win32")(
"rejects broken final symlink targets outside root",
async () => {
await withTempDir(
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
async (root) => {
const outside = path.join(path.dirname(root), "outside");
await fs.mkdir(outside, { recursive: true });
const linkPath = path.join(root, "jump");
await fs.symlink(path.join(outside, "owned.txt"), linkPath);
await expect(
assertNoPathAliasEscape({
absolutePath: linkPath,
rootPath: root,
boundaryLabel: "sandbox root",
}),
).rejects.toThrow(/Symlink escapes sandbox root/);
},
);
it.runIf(process.platform !== "win32").each([
{
name: "rejects broken final symlink targets outside root",
setup: async (root: string) => {
const outside = path.join(path.dirname(root), "outside");
await fs.mkdir(outside, { recursive: true });
const linkPath = path.join(root, "jump");
await fs.symlink(path.join(outside, "owned.txt"), linkPath);
return linkPath;
},
rejects: true,
},
);
it.runIf(process.platform !== "win32")(
"allows broken final symlink targets that remain inside root",
async () => {
await withTempDir(
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
async (root) => {
const linkPath = path.join(root, "jump");
await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath);
await expect(
assertNoPathAliasEscape({
absolutePath: linkPath,
rootPath: root,
boundaryLabel: "sandbox root",
}),
).resolves.toBeUndefined();
},
);
{
name: "allows broken final symlink targets that remain inside root",
setup: async (root: string) => {
const linkPath = path.join(root, "jump");
await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath);
return linkPath;
},
rejects: false,
},
);
it.runIf(process.platform !== "win32")(
"rejects broken targets that traverse via an in-root symlink alias",
async () => {
await withTempDir(
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
async (root) => {
const outside = path.join(path.dirname(root), "outside");
await fs.mkdir(outside, { recursive: true });
await fs.symlink(outside, path.join(root, "hop"));
const linkPath = path.join(root, "jump");
await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath);
await expect(
assertNoPathAliasEscape({
absolutePath: linkPath,
rootPath: root,
boundaryLabel: "sandbox root",
}),
).rejects.toThrow(/Symlink escapes sandbox root/);
},
);
{
name: "rejects broken targets that traverse via an in-root symlink alias",
setup: async (root: string) => {
const outside = path.join(path.dirname(root), "outside");
await fs.mkdir(outside, { recursive: true });
await fs.symlink(outside, path.join(root, "hop"));
const linkPath = path.join(root, "jump");
await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath);
return linkPath;
},
rejects: true,
},
);
])("$name", async ({ setup, rejects }) => {
await withAliasRoot(async (root) => {
const absolutePath = await setup(root);
const promise = assertNoPathAliasEscape({
absolutePath,
rootPath: root,
boundaryLabel: "sandbox root",
});
if (rejects) {
await expect(promise).rejects.toThrow(/Symlink escapes sandbox root/);
return;
}
await expect(promise).resolves.toBeUndefined();
});
});
});

View File

@@ -24,62 +24,69 @@ afterEach(() => {
});
describe("normalizeWindowsPathForComparison", () => {
it("normalizes extended-length and UNC windows paths", () => {
expect(normalizeWindowsPathForComparison("\\\\?\\C:\\Users\\Peter/Repo")).toBe(
"c:\\users\\peter\\repo",
);
expect(normalizeWindowsPathForComparison("\\\\?\\UNC\\Server\\Share\\Folder")).toBe(
"\\\\server\\share\\folder",
);
expect(normalizeWindowsPathForComparison("\\\\?\\unc\\Server\\Share\\Folder")).toBe(
"\\\\server\\share\\folder",
);
it.each([
["\\\\?\\C:\\Users\\Peter/Repo", "c:\\users\\peter\\repo"],
["\\\\?\\UNC\\Server\\Share\\Folder", "\\\\server\\share\\folder"],
["\\\\?\\unc\\Server\\Share\\Folder", "\\\\server\\share\\folder"],
])("normalizes windows path %s", (input, expected) => {
expect(normalizeWindowsPathForComparison(input)).toBe(expected);
});
});
describe("node path error helpers", () => {
it("recognizes node-style error objects and exact codes", () => {
const enoent = { code: "ENOENT" };
expect(isNodeError(enoent)).toBe(true);
expect(isNodeError({ message: "nope" })).toBe(false);
expect(hasNodeErrorCode(enoent, "ENOENT")).toBe(true);
expect(hasNodeErrorCode(enoent, "EACCES")).toBe(false);
it.each([
[{ code: "ENOENT" }, true],
[{ message: "nope" }, false],
])("detects node-style error %j", (value, expected) => {
expect(isNodeError(value)).toBe(expected);
});
it("classifies not-found and symlink-open error codes", () => {
expect(isNotFoundPathError({ code: "ENOENT" })).toBe(true);
expect(isNotFoundPathError({ code: "ENOTDIR" })).toBe(true);
expect(isNotFoundPathError({ code: "EACCES" })).toBe(false);
expect(isNotFoundPathError({ code: 404 })).toBe(false);
it.each([
[{ code: "ENOENT" }, "ENOENT", true],
[{ code: "ENOENT" }, "EACCES", false],
])("matches node error code for %j", (value, code, expected) => {
expect(hasNodeErrorCode(value, code)).toBe(expected);
});
expect(isSymlinkOpenError({ code: "ELOOP" })).toBe(true);
expect(isSymlinkOpenError({ code: "EINVAL" })).toBe(true);
expect(isSymlinkOpenError({ code: "ENOTSUP" })).toBe(true);
expect(isSymlinkOpenError({ code: "ENOENT" })).toBe(false);
expect(isSymlinkOpenError({ code: null })).toBe(false);
it.each([
[{ code: "ENOENT" }, true],
[{ code: "ENOTDIR" }, true],
[{ code: "EACCES" }, false],
[{ code: 404 }, false],
])("classifies not-found path error for %j", (value, expected) => {
expect(isNotFoundPathError(value)).toBe(expected);
});
it.each([
[{ code: "ELOOP" }, true],
[{ code: "EINVAL" }, true],
[{ code: "ENOTSUP" }, true],
[{ code: "ENOENT" }, false],
[{ code: null }, false],
])("classifies symlink-open error for %j", (value, expected) => {
expect(isSymlinkOpenError(value)).toBe(expected);
});
});
describe("isPathInside", () => {
it("accepts identical and nested paths but rejects escapes", () => {
expect(isPathInside("/workspace/root", "/workspace/root")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/nested/file.txt")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/../escape.txt")).toBe(false);
it.each([
["/workspace/root", "/workspace/root", true],
["/workspace/root", "/workspace/root/nested/file.txt", true],
["/workspace/root", "/workspace/root/../escape.txt", false],
])("checks posix containment %s -> %s", (basePath, targetPath, expected) => {
expect(isPathInside(basePath, targetPath)).toBe(expected);
});
it("uses win32 path semantics for windows containment checks", () => {
setPlatform("win32");
expect(isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root`)).toBe(true);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\Nested\File.txt`),
).toBe(true);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..\escape.txt`),
).toBe(false);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`D:\workspace\root\file.txt`),
).toBe(false);
for (const [basePath, targetPath, expected] of [
[String.raw`C:\workspace\root`, String.raw`C:\workspace\root`, true],
[String.raw`C:\workspace\root`, String.raw`C:\workspace\root\Nested\File.txt`, true],
[String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..\escape.txt`, false],
[String.raw`C:\workspace\root`, String.raw`D:\workspace\root\file.txt`, false],
] as const) {
expect(isPathInside(basePath, targetPath)).toBe(expected);
}
});
});

View File

@@ -8,6 +8,7 @@ import {
} from "./path-prepend.js";
const env = (value: Record<string, string>) => value;
const pathLine = (...parts: string[]) => parts.join(path.delimiter);
describe("path prepend helpers", () => {
it.each([
@@ -36,9 +37,9 @@ describe("path prepend helpers", () => {
it.each([
{
existingPath: `/usr/bin${path.delimiter}/opt/bin`,
existingPath: pathLine("/usr/bin", "/opt/bin"),
prepend: ["/custom/bin", "/usr/bin"],
expected: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter),
expected: pathLine("/custom/bin", "/usr/bin", "/opt/bin"),
},
{
existingPath: undefined,
@@ -53,7 +54,7 @@ describe("path prepend helpers", () => {
{
existingPath: ` /usr/bin ${path.delimiter} ${path.delimiter} /opt/bin `,
prepend: ["/custom/bin"],
expected: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter),
expected: pathLine("/custom/bin", "/usr/bin", "/opt/bin"),
},
])("merges prepended paths for %j", ({ existingPath, prepend, expected }) => {
expect(mergePathPrepend(existingPath, prepend)).toBe(expected);
@@ -61,13 +62,13 @@ describe("path prepend helpers", () => {
it("applies prepends to the discovered PATH key and preserves existing casing", () => {
const env = {
Path: [`/usr/bin`, `/opt/bin`].join(path.delimiter),
Path: pathLine("/usr/bin", "/opt/bin"),
};
applyPathPrepend(env, ["/custom/bin", "/usr/bin"]);
expect(env).toEqual({
Path: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter),
Path: pathLine("/custom/bin", "/usr/bin", "/opt/bin"),
});
});
@@ -97,14 +98,19 @@ describe("path prepend helpers", () => {
expect(env).toEqual(expected);
});
it("creates PATH when prepends are provided and no path key exists", () => {
const env = { HOME: "/tmp/home" };
applyPathPrepend(env, ["/custom/bin"]);
expect(env).toEqual({
HOME: "/tmp/home",
PATH: "/custom/bin",
});
it.each([
{
name: "creates PATH when prepends are provided and no path key exists",
env: { HOME: "/tmp/home" },
prepend: ["/custom/bin"],
opts: undefined,
expected: {
HOME: "/tmp/home",
PATH: "/custom/bin",
},
},
])("$name", ({ env, prepend, opts, expected }) => {
applyPathPrepend(env, prepend, opts);
expect(env).toEqual(expected);
});
});

View File

@@ -7,32 +7,13 @@ import {
} from "./ports-format.js";
describe("ports-format", () => {
it("classifies listeners across gateway, ssh, and unknown command lines", () => {
const cases = [
{
listener: { commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" },
expected: "ssh",
},
{
listener: { command: "ssh" },
expected: "ssh",
},
{
listener: { commandLine: "node /Users/me/Projects/openclaw/dist/entry.js gateway" },
expected: "gateway",
},
{
listener: { commandLine: "python -m http.server 18789" },
expected: "unknown",
},
] as const;
for (const testCase of cases) {
expect(
classifyPortListener(testCase.listener, 18789),
JSON.stringify(testCase.listener),
).toBe(testCase.expected);
}
it.each([
[{ commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" }, "ssh"],
[{ command: "ssh" }, "ssh"],
[{ commandLine: "node /Users/me/Projects/openclaw/dist/entry.js gateway" }, "gateway"],
[{ commandLine: "python -m http.server 18789" }, "unknown"],
] as const)("classifies port listener %j", (listener, expected) => {
expect(classifyPortListener(listener, 18789)).toBe(expected);
});
it("builds ordered hints for mixed listener kinds and multiplicity", () => {
@@ -54,14 +35,15 @@ describe("ports-format", () => {
expect(buildPortHints([], 18789)).toEqual([]);
});
it("formats listeners with pid, user, command, and address fallbacks", () => {
expect(
formatPortListener({ pid: 123, user: "alice", commandLine: "ssh -N", address: "::1" }),
).toBe("pid 123 alice: ssh -N (::1)");
expect(formatPortListener({ command: "ssh", address: "127.0.0.1:18789" })).toBe(
"pid ?: ssh (127.0.0.1:18789)",
);
expect(formatPortListener({})).toBe("pid ?: unknown");
it.each([
[
{ pid: 123, user: "alice", commandLine: "ssh -N", address: "::1" },
"pid 123 alice: ssh -N (::1)",
],
[{ command: "ssh", address: "127.0.0.1:18789" }, "pid ?: ssh (127.0.0.1:18789)"],
[{}, "pid ?: unknown"],
] as const)("formats port listener %j", (listener, expected) => {
expect(formatPortListener(listener)).toBe(expected);
});
it("formats free and busy port diagnostics", () => {

View File

@@ -2,6 +2,21 @@ import net from "node:net";
import { describe, expect, it } from "vitest";
import { tryListenOnPort } from "./ports-probe.js";
async function withListeningServer(cb: (address: net.AddressInfo) => Promise<void>): Promise<void> {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected tcp address");
}
try {
await cb(address);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
describe("tryListenOnPort", () => {
it("can bind and release an ephemeral loopback port", async () => {
await expect(tryListenOnPort({ port: 0, host: "127.0.0.1", exclusive: true })).resolves.toBe(
@@ -10,21 +25,12 @@ describe("tryListenOnPort", () => {
});
it("rejects when the port is already in use", async () => {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected tcp address");
}
try {
await withListeningServer(async (address) => {
await expect(
tryListenOnPort({ port: address.port, host: "127.0.0.1" }),
).rejects.toMatchObject({
code: "EADDRINUSE",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
});

View File

@@ -17,9 +17,11 @@ function makeSnapshot(windows: ProviderUsageSnapshot["windows"]): ProviderUsageS
}
describe("provider-usage.format", () => {
it("returns null summary for errored or empty snapshots", () => {
expect(formatUsageWindowSummary({ ...makeSnapshot([]), error: "HTTP 401" })).toBeNull();
expect(formatUsageWindowSummary(makeSnapshot([]))).toBeNull();
it.each([
{ snapshot: { ...makeSnapshot([]), error: "HTTP 401" } as ProviderUsageSnapshot, now },
{ snapshot: makeSnapshot([]), now },
])("returns null summary for empty or errored snapshots", ({ snapshot, now: currentNow }) => {
expect(formatUsageWindowSummary(snapshot, { now: currentNow })).toBeNull();
});
it("formats reset windows across now/minute/hour/day/date buckets", () => {
@@ -112,52 +114,52 @@ describe("provider-usage.format", () => {
).toBeNull();
});
it("formats report output for empty, error, no-data, and plan entries", () => {
expect(formatUsageReportLines({ updatedAt: now, providers: [] })).toEqual([
"Usage: no provider usage available.",
]);
const summary: UsageSummary = {
updatedAt: now,
providers: [
{
provider: "openai-codex",
displayName: "Codex",
windows: [],
error: "Token expired",
plan: "Plus",
},
{
provider: "xiaomi",
displayName: "Xiaomi",
windows: [],
},
],
};
expect(formatUsageReportLines(summary)).toEqual([
"Usage:",
" Codex (Plus): Token expired",
" Xiaomi: no data",
]);
});
it("formats detailed report lines with reset windows", () => {
const summary: UsageSummary = {
updatedAt: now,
providers: [
{
provider: "anthropic",
displayName: "Claude",
plan: "Pro",
windows: [{ label: "Daily", usedPercent: 25, resetAt: now + 2 * 60 * 60_000 }],
},
],
};
expect(formatUsageReportLines(summary, { now })).toEqual([
"Usage:",
" Claude (Pro)",
" Daily: 75% left · resets 2h",
]);
it.each([
{
name: "formats empty reports",
summary: { updatedAt: now, providers: [] } as UsageSummary,
opts: undefined,
expected: ["Usage: no provider usage available."],
},
{
name: "formats error, no-data, and plan entries",
summary: {
updatedAt: now,
providers: [
{
provider: "openai-codex",
displayName: "Codex",
windows: [],
error: "Token expired",
plan: "Plus",
},
{
provider: "xiaomi",
displayName: "Xiaomi",
windows: [],
},
],
} as UsageSummary,
opts: undefined,
expected: ["Usage:", " Codex (Plus): Token expired", " Xiaomi: no data"],
},
{
name: "formats detailed report lines with reset windows",
summary: {
updatedAt: now,
providers: [
{
provider: "anthropic",
displayName: "Claude",
plan: "Pro",
windows: [{ label: "Daily", usedPercent: 25, resetAt: now + 2 * 60 * 60_000 }],
},
],
} as UsageSummary,
opts: { now },
expected: ["Usage:", " Claude (Pro)", " Daily: 75% left · resets 2h"],
},
])("$name", ({ summary, opts, expected }) => {
expect(formatUsageReportLines(summary, opts)).toEqual(expected);
});
});

View File

@@ -9,6 +9,21 @@ import {
withTimeout,
} from "./provider-usage.shared.js";
async function withLegacyPiAuthFile(
contents: string,
run: (home: string) => Promise<void> | void,
): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-"));
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
await fs.writeFile(path.join(home, ".pi", "agent", "auth.json"), contents, "utf8");
try {
await run(home);
} finally {
await fs.rm(home, { recursive: true, force: true });
}
}
describe("provider-usage.shared", () => {
afterEach(() => {
vi.useRealTimers();
@@ -35,14 +50,23 @@ describe("provider-usage.shared", () => {
expect(clampPercent(value)).toBe(expected);
});
it("returns work result when it resolves before timeout", async () => {
await expect(withTimeout(Promise.resolve("ok"), 100, "fallback")).resolves.toBe("ok");
});
it("propagates work errors before timeout", async () => {
await expect(withTimeout(Promise.reject(new Error("boom")), 100, "fallback")).rejects.toThrow(
"boom",
);
it.each([
{
name: "returns work result when it resolves before timeout",
promise: () => Promise.resolve("ok"),
expected: "ok",
},
{
name: "propagates work errors before timeout",
promise: () => Promise.reject(new Error("boom")),
error: "boom",
},
])("$name", async ({ promise, expected, error }) => {
if (error) {
await expect(withTimeout(promise(), 100, "fallback")).rejects.toThrow(error);
return;
}
await expect(withTimeout(promise(), 100, "fallback")).resolves.toBe(expected);
});
it("returns fallback when timeout wins", async () => {
@@ -61,33 +85,20 @@ describe("provider-usage.shared", () => {
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
it("reads legacy pi auth tokens for known provider aliases", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-"));
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
await fs.writeFile(
path.join(home, ".pi", "agent", "auth.json"),
`${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`,
"utf8",
);
try {
expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBe(
"legacy-zai-key",
);
} finally {
await fs.rm(home, { recursive: true, force: true });
}
});
it("returns undefined for invalid legacy pi auth files", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-"));
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
await fs.writeFile(path.join(home, ".pi", "agent", "auth.json"), "{not-json", "utf8");
try {
expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBeUndefined();
} finally {
await fs.rm(home, { recursive: true, force: true });
}
it.each([
{
name: "reads legacy pi auth tokens for known provider aliases",
contents: `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`,
expected: "legacy-zai-key",
},
{
name: "returns undefined for invalid legacy pi auth files",
contents: "{not-json",
expected: undefined,
},
])("$name", async ({ contents, expected }) => {
await withLegacyPiAuthFile(contents, async (home) => {
expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBe(expected);
});
});
});

View File

@@ -9,6 +9,24 @@ import {
let executables: Set<string>;
function addExecutables(...paths: string[]): void {
for (const candidate of paths) {
executables.add(candidate);
}
}
function expectDirsContainAll(dirs: readonly string[], expected: readonly string[]): void {
for (const dir of expected) {
expect(dirs).toContain(dir);
}
}
function expectDirsExcludeAll(dirs: readonly string[], excluded: readonly string[]): void {
for (const dir of excluded) {
expect(dirs).not.toContain(dir);
}
}
beforeEach(() => {
executables = new Set<string>();
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
@@ -30,21 +48,31 @@ describe("resolveSystemBin", () => {
expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg");
});
it("does NOT resolve a binary found in /usr/local/bin with strict trust", () => {
executables.add("/usr/local/bin/openssl");
expect(resolveSystemBin("openssl")).toBeNull();
expect(resolveSystemBin("openssl", { trust: "strict" })).toBeNull();
});
it("does NOT resolve a binary found in /opt/homebrew/bin with strict trust", () => {
executables.add("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBeNull();
expect(resolveSystemBin("ffmpeg", { trust: "strict" })).toBeNull();
});
it("does NOT resolve a binary from a user-writable directory like ~/.local/bin", () => {
executables.add("/home/testuser/.local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBeNull();
it.each([
{
name: "does NOT resolve a binary found in /usr/local/bin with strict trust",
executable: "/usr/local/bin/openssl",
command: "openssl",
checkStrict: true,
},
{
name: "does NOT resolve a binary found in /opt/homebrew/bin with strict trust",
executable: "/opt/homebrew/bin/ffmpeg",
command: "ffmpeg",
checkStrict: true,
},
{
name: "does NOT resolve a binary from a user-writable directory like ~/.local/bin",
executable: "/home/testuser/.local/bin/ffmpeg",
command: "ffmpeg",
checkStrict: false,
},
])("$name", ({ executable, command, checkStrict }) => {
addExecutables(executable);
expect(resolveSystemBin(command)).toBeNull();
if (checkStrict) {
expect(resolveSystemBin(command, { trust: "strict" })).toBeNull();
}
});
it("prefers /usr/bin over /usr/local/bin (first match wins)", () => {
@@ -79,15 +107,13 @@ describe("resolveSystemBin", () => {
}
if (process.platform === "darwin") {
it("resolves a binary in /opt/homebrew/bin with standard trust on macOS", () => {
executables.add("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/opt/homebrew/bin/ffmpeg");
});
it("resolves a binary in /usr/local/bin with standard trust on macOS", () => {
executables.add("/usr/local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg");
});
it.each(["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"])(
"resolves a binary in %s with standard trust on macOS",
(executable) => {
addExecutables(executable);
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe(executable);
},
);
it("prefers /usr/bin over /opt/homebrew/bin with standard trust", () => {
executables.add("/usr/bin/ffmpeg");
@@ -112,7 +138,7 @@ describe("resolveSystemBin", () => {
if (process.platform === "linux") {
it("resolves a binary in /usr/local/bin with standard trust on Linux", () => {
executables.add("/usr/local/bin/ffmpeg");
addExecutables("/usr/local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg");
});
@@ -136,11 +162,8 @@ describe("trusted directory list", () => {
if (process.platform !== "win32") {
it("includes base Unix system directories only", () => {
const dirs = _getTrustedDirs();
expect(dirs).toContain("/usr/bin");
expect(dirs).toContain("/bin");
expect(dirs).toContain("/usr/sbin");
expect(dirs).toContain("/sbin");
expect(dirs).not.toContain("/usr/local/bin");
expectDirsContainAll(dirs, ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]);
expectDirsExcludeAll(dirs, ["/usr/local/bin"]);
});
it("ignores env-controlled NIX_PROFILES entries, including direct store paths", () => {
@@ -150,10 +173,12 @@ describe("trusted directory list", () => {
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1 /tmp/evil /home/user/.nix-profile /nix/var/nix/profiles/default";
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
const dirs = _getTrustedDirs();
expect(dirs).not.toContain("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin");
expect(dirs).not.toContain("/tmp/evil/bin");
expect(dirs).not.toContain("/home/user/.nix-profile/bin");
expect(dirs).not.toContain("/nix/var/nix/profiles/default/bin");
expectDirsExcludeAll(dirs, [
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin",
"/tmp/evil/bin",
"/home/user/.nix-profile/bin",
"/nix/var/nix/profiles/default/bin",
]);
} finally {
if (saved === undefined) {
delete process.env.NIX_PROFILES;
@@ -167,14 +192,12 @@ describe("trusted directory list", () => {
if (process.platform === "darwin") {
it("does not include /opt/homebrew/bin in strict trust on macOS", () => {
expect(_getTrustedDirs("strict")).not.toContain("/opt/homebrew/bin");
expect(_getTrustedDirs("strict")).not.toContain("/usr/local/bin");
expectDirsExcludeAll(_getTrustedDirs("strict"), ["/opt/homebrew/bin", "/usr/local/bin"]);
});
it("includes /opt/homebrew/bin and /usr/local/bin in standard trust on macOS", () => {
const dirs = _getTrustedDirs("standard");
expect(dirs).toContain("/opt/homebrew/bin");
expect(dirs).toContain("/usr/local/bin");
expectDirsContainAll(dirs, ["/opt/homebrew/bin", "/usr/local/bin"]);
});
it("places Homebrew dirs after system dirs in standard trust", () => {
@@ -199,8 +222,7 @@ describe("trusted directory list", () => {
if (process.platform === "linux") {
it("includes Linux system-managed directories", () => {
const dirs = _getTrustedDirs();
expect(dirs).toContain("/run/current-system/sw/bin");
expect(dirs).toContain("/snap/bin");
expectDirsContainAll(dirs, ["/run/current-system/sw/bin", "/snap/bin"]);
});
it("includes /usr/local/bin in standard trust on Linux", () => {
@@ -285,13 +307,11 @@ describe("trusted directory list", () => {
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
const dirs = _getTrustedDirs();
const normalizedDirs = dirs.map((dir) => dir.toLowerCase());
expect(normalizedDirs).toContain(path.win32.join("C:\\Windows", "System32").toLowerCase());
expect(normalizedDirs).toContain(
expectDirsContainAll(normalizedDirs, [
path.win32.join("C:\\Windows", "System32").toLowerCase(),
path.win32.join("C:\\Program Files", "OpenSSL-Win64", "bin").toLowerCase(),
);
expect(normalizedDirs).toContain(
path.win32.join("C:\\Program Files (x86)", "OpenSSL", "bin").toLowerCase(),
);
]);
});
it("does not include Unix paths on Windows", () => {

View File

@@ -1,6 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveRetryConfig, retryAsync } from "./retry.js";
type NumberRetryCase = {
name: string;
fn: ReturnType<typeof vi.fn>;
attempts: number;
initialDelayMs: number;
expectedValue?: string;
expectedError?: string;
expectedCalls: number;
};
async function runRetryAfterCase(params: {
minDelayMs: number;
maxDelayMs: number;
@@ -28,15 +38,15 @@ async function runRetryAfterCase(params: {
}
}
async function runRetryNumberCase<T>(
fn: ReturnType<typeof vi.fn<() => Promise<T>>>,
async function runRetryNumberCase(
fn: ReturnType<typeof vi.fn>,
attempts: number,
initialDelayMs: number,
): Promise<T> {
): Promise<unknown> {
vi.clearAllTimers();
vi.useFakeTimers();
try {
const promise = retryAsync(fn, attempts, initialDelayMs);
const promise = retryAsync(fn as () => Promise<unknown>, attempts, initialDelayMs);
const settled = promise.then(
(value) => ({ ok: true as const, value }),
(error) => ({ ok: false as const, error }),
@@ -64,25 +74,43 @@ beforeEach(() => {
});
describe("retryAsync", () => {
it("returns on first success", async () => {
const fn = vi.fn().mockResolvedValue("ok");
const result = await retryAsync(fn, 3, 10);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries then succeeds", async () => {
const fn = vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok");
const result = await runRetryNumberCase(fn, 3, 1);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("propagates after exhausting retries", async () => {
const fn = vi.fn().mockRejectedValue(new Error("boom"));
await expect(runRetryNumberCase(fn, 2, 1)).rejects.toThrow("boom");
expect(fn).toHaveBeenCalledTimes(2);
});
it.each<NumberRetryCase>([
{
name: "returns on first success",
fn: vi.fn().mockResolvedValue("ok"),
attempts: 3,
initialDelayMs: 10,
expectedValue: "ok",
expectedCalls: 1,
},
{
name: "retries then succeeds",
fn: vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok"),
attempts: 3,
initialDelayMs: 1,
expectedValue: "ok",
expectedCalls: 2,
},
{
name: "propagates after exhausting retries",
fn: vi.fn().mockRejectedValue(new Error("boom")),
attempts: 2,
initialDelayMs: 1,
expectedError: "boom",
expectedCalls: 2,
},
])(
"$name",
async ({ fn, attempts, initialDelayMs, expectedValue, expectedError, expectedCalls }) => {
const result = runRetryNumberCase(fn, attempts, initialDelayMs);
if (expectedError) {
await expect(result).rejects.toThrow(expectedError);
} else {
await expect(result).resolves.toBe(expectedValue);
}
expect(fn).toHaveBeenCalledTimes(expectedCalls);
},
);
it("stops when shouldRetry returns false", async () => {
const err = new Error("boom");
@@ -133,19 +161,25 @@ describe("retryAsync", () => {
expect(fn).toHaveBeenCalledTimes(1);
});
it("uses retryAfterMs when provided", async () => {
const delays = await runRetryAfterCase({ minDelayMs: 0, maxDelayMs: 1000, retryAfterMs: 500 });
expect(delays[0]).toBe(500);
});
it("clamps retryAfterMs to maxDelayMs", async () => {
const delays = await runRetryAfterCase({ minDelayMs: 0, maxDelayMs: 100, retryAfterMs: 500 });
expect(delays[0]).toBe(100);
});
it("clamps retryAfterMs to minDelayMs", async () => {
const delays = await runRetryAfterCase({ minDelayMs: 250, maxDelayMs: 1000, retryAfterMs: 50 });
expect(delays[0]).toBe(250);
it.each([
{
name: "uses retryAfterMs when provided",
params: { minDelayMs: 0, maxDelayMs: 1000, retryAfterMs: 500 },
expectedDelay: 500,
},
{
name: "clamps retryAfterMs to maxDelayMs",
params: { minDelayMs: 0, maxDelayMs: 100, retryAfterMs: 500 },
expectedDelay: 100,
},
{
name: "clamps retryAfterMs to minDelayMs",
params: { minDelayMs: 250, maxDelayMs: 1000, retryAfterMs: 50 },
expectedDelay: 250,
},
])("$name", async ({ params, expectedDelay }) => {
const delays = await runRetryAfterCase(params);
expect(delays[0]).toBe(expectedDelay);
});
});

View File

@@ -65,6 +65,17 @@ async function expectOpenFailure(params: {
});
}
function expectOpenReason(
opened: ReturnType<typeof openVerifiedFileSync>,
expectedReason: "path" | "validation" | "io",
): void {
expect(opened.ok).toBe(false);
if (opened.ok) {
return;
}
expect(opened.reason).toBe(expectedReason);
}
describe("openVerifiedFileSync", () => {
it.each([
{
@@ -152,10 +163,7 @@ describe("openVerifiedFileSync", () => {
filePath: "/input/file.txt",
ioFs,
});
expect(opened.ok).toBe(false);
if (!opened.ok) {
expect(opened.reason).toBe("validation");
}
expectOpenReason(opened, "validation");
expect(closed).toEqual([42]);
});
@@ -178,9 +186,6 @@ describe("openVerifiedFileSync", () => {
rejectPathSymlink: true,
ioFs,
});
expect(opened.ok).toBe(false);
if (!opened.ok) {
expect(opened.reason).toBe("io");
}
expectOpenReason(opened, "io");
});
});

View File

@@ -40,38 +40,40 @@ describe("scp remote host", () => {
});
describe("scp remote path", () => {
it.each([
{
value: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
expected: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
},
{
value: " /Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg ",
expected: "/Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg",
},
])("normalizes safe paths for %j", ({ value, expected }) => {
expect(normalizeScpRemotePath(value)).toBe(expected);
expect(isSafeScpRemotePath(value)).toBe(true);
});
it.each([
null,
undefined,
"",
" ",
"relative/path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad$path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad`path`.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad;path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad|path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad&path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad<path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad>path.jpg",
'/Users/demo/Library/Messages/Attachments/ab/cd/bad"path.jpg',
"/Users/demo/Library/Messages/Attachments/ab/cd/bad'path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad\\path.jpg",
])("rejects unsafe path tokens: %j", (value) => {
expect(normalizeScpRemotePath(value)).toBeUndefined();
expect(isSafeScpRemotePath(value)).toBe(false);
it.each(
[
{
value: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
normalized: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
safe: true,
},
{
value: " /Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg ",
normalized: "/Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg",
safe: true,
},
null,
undefined,
"",
" ",
"relative/path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad$path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad`path`.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad;path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad|path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad&path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad<path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad>path.jpg",
'/Users/demo/Library/Messages/Attachments/ab/cd/bad"path.jpg',
"/Users/demo/Library/Messages/Attachments/ab/cd/bad'path.jpg",
"/Users/demo/Library/Messages/Attachments/ab/cd/bad\\path.jpg",
].map((entry) =>
typeof entry === "object" && entry !== null && "value" in entry
? entry
: { value: entry, normalized: undefined, safe: false },
),
)("classifies path token %j", ({ value, normalized, safe }) => {
expect(normalizeScpRemotePath(value)).toBe(normalized);
expect(isSafeScpRemotePath(value)).toBe(safe);
});
});

View File

@@ -16,6 +16,24 @@ afterEach(async () => {
await tempDirs.cleanup();
});
async function expectSecretFileError(params: {
setup: (dir: string) => Promise<string>;
expectedMessage: (file: string) => string;
secretLabel?: string;
options?: Parameters<typeof readSecretFileSync>[2];
}): Promise<void> {
const dir = await createTempDir();
const file = await params.setup(dir);
expect(() =>
readSecretFileSync(file, params.secretLabel ?? "Gateway password", params.options),
).toThrow(params.expectedMessage(file));
}
async function createSecretPath(setup: (dir: string) => Promise<string>): Promise<string> {
const dir = await createTempDir();
return setup(dir);
}
describe("readSecretFileSync", () => {
it("rejects blank file paths", () => {
expect(() => readSecretFileSync(" ", "Gateway password")).toThrow(
@@ -32,104 +50,140 @@ describe("readSecretFileSync", () => {
expect(tryReadSecretFileSync(file, "Gateway password")).toBe("top-secret");
});
it("surfaces resolvedPath and error details for missing files", async () => {
const dir = await createTempDir();
const file = path.join(dir, "missing-secret.txt");
it.each([
{
name: "surfaces resolvedPath and error details for missing files",
assert: (file: string) => {
expect(loadSecretFileSync(file, "Gateway password")).toMatchObject({
ok: false,
resolvedPath: file,
message: expect.stringContaining(`Failed to inspect Gateway password file at ${file}:`),
error: expect.any(Error),
});
},
},
{
name: "preserves the underlying cause when throwing for missing files",
assert: (file: string) => {
let thrown: Error | undefined;
try {
readSecretFileSync(file, "Gateway password");
} catch (error) {
thrown = error as Error;
}
const result = loadSecretFileSync(file, "Gateway password");
expect(result).toMatchObject({
ok: false,
resolvedPath: file,
message: expect.stringContaining(`Failed to inspect Gateway password file at ${file}:`),
error: expect.any(Error),
});
expect(thrown).toBeInstanceOf(Error);
expect(thrown?.message).toContain(`Failed to inspect Gateway password file at ${file}:`);
expect((thrown as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
},
},
])("$name", async ({ assert }) => {
const file = await createSecretPath(async (dir) => path.join(dir, "missing-secret.txt"));
assert(file);
});
it("preserves the underlying cause when throwing for missing files", async () => {
const dir = await createTempDir();
const file = path.join(dir, "missing-secret.txt");
it.each([
{
name: "rejects files larger than the secret-file limit",
setup: async (dir: string) => {
const file = path.join(dir, "secret.txt");
await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
return file;
},
expectedMessage: (file: string) =>
`Gateway password file at ${file} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`,
},
{
name: "rejects non-regular files",
setup: async (dir: string) => {
const nestedDir = path.join(dir, "secret-dir");
await mkdir(nestedDir);
return nestedDir;
},
expectedMessage: (file: string) => `Gateway password file at ${file} must be a regular file.`,
},
{
name: "rejects symlinks when configured",
setup: async (dir: string) => {
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
return link;
},
options: { rejectSymlink: true },
expectedMessage: (file: string) => `Gateway password file at ${file} must not be a symlink.`,
},
{
name: "rejects empty secret files after trimming",
setup: async (dir: string) => {
const file = path.join(dir, "secret.txt");
await writeFile(file, " \n\t ", "utf8");
return file;
},
expectedMessage: (file: string) => `Gateway password file at ${file} is empty.`,
},
])("$name", async ({ setup, expectedMessage, options }) => {
await expectSecretFileError({ setup, expectedMessage, options });
});
let thrown: Error | undefined;
try {
readSecretFileSync(file, "Gateway password");
} catch (error) {
thrown = error as Error;
it.each([
{
name: "exposes resolvedPath on non-throwing read failures",
pathValue: async () =>
createSecretPath(async (dir) => {
const file = path.join(dir, "secret.txt");
await writeFile(file, " \n\t ", "utf8");
return file;
}),
label: "Gateway password",
options: undefined,
helper: "load" as const,
expected: (file: string | undefined) => ({
ok: false,
resolvedPath: file,
message: `Gateway password file at ${file} is empty.`,
}),
},
{
name: "returns undefined from the non-throwing helper for rejected files",
pathValue: async () =>
createSecretPath(async (dir) => {
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
return link;
}),
label: "Telegram bot token",
options: { rejectSymlink: true },
helper: "try" as const,
expected: () => undefined,
},
{
name: "returns undefined from the non-throwing helper for blank file paths",
pathValue: async () => " ",
label: "Telegram bot token",
options: undefined,
helper: "try" as const,
expected: () => undefined,
},
{
name: "returns undefined from the non-throwing helper for missing path values",
pathValue: async () => undefined,
label: "Telegram bot token",
options: undefined,
helper: "try" as const,
expected: () => undefined,
},
])("$name", async ({ pathValue, label, options, helper, expected }) => {
const file = await pathValue();
if (helper === "load") {
expect(loadSecretFileSync(file as string, label, options)).toMatchObject(
(expected as (file: string | undefined) => Record<string, unknown>)(file),
);
return;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown?.message).toContain(`Failed to inspect Gateway password file at ${file}:`);
expect((thrown as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
});
it("rejects files larger than the secret-file limit", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
expect(() => readSecretFileSync(file, "Gateway password")).toThrow(
`Gateway password file at ${file} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`,
);
});
it("rejects non-regular files", async () => {
const dir = await createTempDir();
const nestedDir = path.join(dir, "secret-dir");
await mkdir(nestedDir);
expect(() => readSecretFileSync(nestedDir, "Gateway password")).toThrow(
`Gateway password file at ${nestedDir} must be a regular file.`,
);
});
it("rejects symlinks when configured", async () => {
const dir = await createTempDir();
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
expect(() => readSecretFileSync(link, "Gateway password", { rejectSymlink: true })).toThrow(
`Gateway password file at ${link} must not be a symlink.`,
);
});
it("rejects empty secret files after trimming", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, " \n\t ", "utf8");
expect(() => readSecretFileSync(file, "Gateway password")).toThrow(
`Gateway password file at ${file} is empty.`,
);
});
it("exposes resolvedPath on non-throwing read failures", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, " \n\t ", "utf8");
expect(loadSecretFileSync(file, "Gateway password")).toMatchObject({
ok: false,
resolvedPath: file,
message: `Gateway password file at ${file} is empty.`,
});
});
it("returns undefined from the non-throwing helper for rejected files", async () => {
const dir = await createTempDir();
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
expect(tryReadSecretFileSync(link, "Telegram bot token", { rejectSymlink: true })).toBe(
undefined,
);
});
it("returns undefined from the non-throwing helper for blank file paths", () => {
expect(tryReadSecretFileSync(" ", "Telegram bot token")).toBeUndefined();
expect(tryReadSecretFileSync(undefined, "Telegram bot token")).toBeUndefined();
expect(tryReadSecretFileSync(file, label, options)).toBe((expected as () => undefined)());
});
});

View File

@@ -28,31 +28,32 @@ describe("secure-random", () => {
expect(cryptoMocks.randomUUID).toHaveBeenCalledTimes(2);
});
it("generates url-safe tokens with the default byte count", () => {
it.each([
{
name: "uses the default byte count",
byteCount: undefined,
expectedBytes: 16,
expectedToken: Buffer.alloc(16, 0xab).toString("base64url"),
},
{
name: "passes custom byte counts through",
byteCount: 18,
expectedBytes: 18,
expectedToken: Buffer.alloc(18, 0xab).toString("base64url"),
},
{
name: "supports zero-byte tokens",
byteCount: 0,
expectedBytes: 0,
expectedToken: "",
},
])("generates url-safe tokens when $name", ({ byteCount, expectedBytes, expectedToken }) => {
cryptoMocks.randomBytes.mockClear();
const defaultToken = generateSecureToken();
const token = byteCount === undefined ? generateSecureToken() : generateSecureToken(byteCount);
expect(cryptoMocks.randomBytes).toHaveBeenCalledWith(16);
expect(defaultToken).toMatch(/^[A-Za-z0-9_-]+$/);
expect(defaultToken).toHaveLength(Buffer.alloc(16, 0xab).toString("base64url").length);
});
it("passes custom byte counts through to crypto.randomBytes", () => {
cryptoMocks.randomBytes.mockClear();
const token18 = generateSecureToken(18);
expect(cryptoMocks.randomBytes).toHaveBeenCalledWith(18);
expect(token18).toBe(Buffer.alloc(18, 0xab).toString("base64url"));
});
it("supports zero-byte tokens without rewriting the requested size", () => {
cryptoMocks.randomBytes.mockClear();
const token = generateSecureToken(0);
expect(cryptoMocks.randomBytes).toHaveBeenCalledWith(0);
expect(token).toBe("");
expect(cryptoMocks.randomBytes).toHaveBeenCalledWith(expectedBytes);
expect(token).toBe(expectedToken);
expect(token).toMatch(/^[A-Za-z0-9_-]*$/);
});
});

View File

@@ -6,67 +6,59 @@ import {
} from "./shell-inline-command.js";
describe("resolveInlineCommandMatch", () => {
it("extracts the next token for exact inline-command flags", () => {
expect(
resolveInlineCommandMatch(["bash", "-lc", "echo hi"], POSIX_INLINE_COMMAND_FLAGS),
).toEqual({
command: "echo hi",
valueTokenIndex: 2,
});
expect(
resolveInlineCommandMatch(
["pwsh", "-Command", "Get-ChildItem"],
POWERSHELL_INLINE_COMMAND_FLAGS,
),
).toEqual({
command: "Get-ChildItem",
valueTokenIndex: 2,
});
expect(
resolveInlineCommandMatch(["pwsh", "-File", "script.ps1"], POWERSHELL_INLINE_COMMAND_FLAGS),
).toEqual({
command: "script.ps1",
valueTokenIndex: 2,
});
expect(
resolveInlineCommandMatch(
["powershell", "-f", "script.ps1"],
POWERSHELL_INLINE_COMMAND_FLAGS,
),
).toEqual({
command: "script.ps1",
valueTokenIndex: 2,
});
});
it("supports combined -c forms only when enabled", () => {
expect(
resolveInlineCommandMatch(["sh", "-cecho hi"], POSIX_INLINE_COMMAND_FLAGS, {
allowCombinedC: true,
}),
).toEqual({
command: "echo hi",
valueTokenIndex: 1,
});
expect(
resolveInlineCommandMatch(["sh", "-cecho hi"], POSIX_INLINE_COMMAND_FLAGS, {
allowCombinedC: false,
}),
).toEqual({
command: null,
valueTokenIndex: null,
});
});
it("returns a value index even when the flag is present without a usable command", () => {
expect(resolveInlineCommandMatch(["bash", "-lc", " "], POSIX_INLINE_COMMAND_FLAGS)).toEqual({
command: null,
valueTokenIndex: 2,
});
expect(resolveInlineCommandMatch(["bash", "-lc"], POSIX_INLINE_COMMAND_FLAGS)).toEqual({
command: null,
valueTokenIndex: null,
});
it.each([
{
name: "extracts the next token for bash -lc",
argv: ["bash", "-lc", "echo hi"],
flags: POSIX_INLINE_COMMAND_FLAGS,
expected: { command: "echo hi", valueTokenIndex: 2 },
},
{
name: "extracts the next token for PowerShell -Command",
argv: ["pwsh", "-Command", "Get-ChildItem"],
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
expected: { command: "Get-ChildItem", valueTokenIndex: 2 },
},
{
name: "extracts the next token for PowerShell -File",
argv: ["pwsh", "-File", "script.ps1"],
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
expected: { command: "script.ps1", valueTokenIndex: 2 },
},
{
name: "extracts the next token for PowerShell -f",
argv: ["powershell", "-f", "script.ps1"],
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
expected: { command: "script.ps1", valueTokenIndex: 2 },
},
{
name: "supports combined -c forms when enabled",
argv: ["sh", "-cecho hi"],
flags: POSIX_INLINE_COMMAND_FLAGS,
opts: { allowCombinedC: true },
expected: { command: "echo hi", valueTokenIndex: 1 },
},
{
name: "rejects combined -c forms when disabled",
argv: ["sh", "-cecho hi"],
flags: POSIX_INLINE_COMMAND_FLAGS,
opts: { allowCombinedC: false },
expected: { command: null, valueTokenIndex: null },
},
{
name: "returns a value index for blank command tokens",
argv: ["bash", "-lc", " "],
flags: POSIX_INLINE_COMMAND_FLAGS,
expected: { command: null, valueTokenIndex: 2 },
},
{
name: "returns null value index when the flag has no following token",
argv: ["bash", "-lc"],
flags: POSIX_INLINE_COMMAND_FLAGS,
expected: { command: null, valueTokenIndex: null },
},
])("$name", ({ argv, flags, opts, expected }) => {
expect(resolveInlineCommandMatch(argv, flags, opts)).toEqual(expected);
});
it("stops parsing after --", () => {

View File

@@ -3,30 +3,53 @@ import { SYSTEM_MARK, hasSystemMark, prefixSystemMessage } from "./system-messag
describe("system-message", () => {
it.each([
{ input: "thread notice", expected: `${SYSTEM_MARK} thread notice` },
{ input: ` thread notice `, expected: `${SYSTEM_MARK} thread notice` },
{ input: " ", expected: "" },
])("prefixes %j", ({ input, expected }) => {
expect(prefixSystemMessage(input)).toBe(expected);
});
it.each([
{ input: `${SYSTEM_MARK} already prefixed`, expected: true },
{ input: ` ${SYSTEM_MARK} hello`, expected: true },
{ input: SYSTEM_MARK, expected: true },
{ input: "", expected: false },
{ input: "hello", expected: false },
])("detects marks for %j", ({ input, expected }) => {
expect(hasSystemMark(input)).toBe(expected);
});
it("does not double-prefix messages that already have the mark", () => {
expect(prefixSystemMessage(`${SYSTEM_MARK} already prefixed`)).toBe(
`${SYSTEM_MARK} already prefixed`,
);
});
it("preserves mark-only messages after trimming", () => {
expect(prefixSystemMessage(` ${SYSTEM_MARK} `)).toBe(SYSTEM_MARK);
{
input: "thread notice",
prefixed: `${SYSTEM_MARK} thread notice`,
marked: false,
},
{
input: ` thread notice `,
prefixed: `${SYSTEM_MARK} thread notice`,
marked: false,
},
{
input: " ",
prefixed: "",
marked: false,
},
{
input: `${SYSTEM_MARK} already prefixed`,
prefixed: `${SYSTEM_MARK} already prefixed`,
marked: true,
},
{
input: ` ${SYSTEM_MARK} hello`,
prefixed: `${SYSTEM_MARK} hello`,
marked: true,
},
{
input: SYSTEM_MARK,
prefixed: SYSTEM_MARK,
marked: true,
},
{
input: ` ${SYSTEM_MARK} `,
prefixed: SYSTEM_MARK,
marked: true,
},
{
input: "",
prefixed: "",
marked: false,
},
{
input: "hello",
prefixed: `${SYSTEM_MARK} hello`,
marked: false,
},
])("handles %j", ({ input, prefixed, marked }) => {
expect(prefixSystemMessage(input)).toBe(prefixed);
expect(hasSystemMark(input)).toBe(marked);
});
});

View File

@@ -2,16 +2,20 @@ import { describe, expect, it } from "vitest";
import { normalizeNonEmptyString, normalizeStringArray } from "./system-run-normalize.js";
describe("system run normalization helpers", () => {
it("normalizes only non-empty trimmed strings", () => {
expect(normalizeNonEmptyString(" hello ")).toBe("hello");
expect(normalizeNonEmptyString(" \n\t ")).toBeNull();
expect(normalizeNonEmptyString(42)).toBeNull();
expect(normalizeNonEmptyString(null)).toBeNull();
it.each([
{ value: " hello ", expected: "hello" },
{ value: " \n\t ", expected: null },
{ value: 42, expected: null },
{ value: null, expected: null },
])("normalizes non-empty strings for %j", ({ value, expected }) => {
expect(normalizeNonEmptyString(value)).toBe(expected);
});
it("normalizes array entries and rejects non-arrays", () => {
expect(normalizeStringArray([" alpha ", 42, false])).toEqual([" alpha ", "42", "false"]);
expect(normalizeStringArray(undefined)).toEqual([]);
expect(normalizeStringArray("alpha")).toEqual([]);
it.each([
{ value: [" alpha ", 42, false], expected: [" alpha ", "42", "false"] },
{ value: undefined, expected: [] },
{ value: "alpha", expected: [] },
])("normalizes string arrays for %j", ({ value, expected }) => {
expect(normalizeStringArray(value)).toEqual(expected);
});
});

View File

@@ -24,6 +24,19 @@ function makeZoneOpts(overrides: Partial<WideAreaGatewayZoneOpts> = {}): WideAre
return { ...baseZoneOpts, ...overrides };
}
function renderZoneText(overrides: Partial<WideAreaGatewayZoneOpts> = {}): string {
return renderWideAreaGatewayZoneText({
...makeZoneOpts(overrides),
serial: 2025121701,
});
}
function expectZoneRecords(text: string, records: string[]): void {
for (const record of records) {
expect(text).toContain(record);
}
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -79,68 +92,56 @@ describe("wide-area DNS discovery domain helpers", () => {
describe("wide-area DNS-SD zone rendering", () => {
it("renders a zone with gateway PTR/SRV/TXT records", () => {
const txt = renderWideAreaGatewayZoneText({
domain: "openclaw.internal.",
serial: 2025121701,
gatewayPort: 18789,
displayName: "Mac Studio (OpenClaw)",
tailnetIPv4: "100.123.224.76",
const txt = renderZoneText({
tailnetIPv6: "fd7a:115c:a1e0::8801:e04c",
hostLabel: "studio-london",
instanceLabel: "studio-london",
sshPort: 22,
cliPath: "/opt/homebrew/bin/openclaw",
});
expect(txt).toContain(`$ORIGIN openclaw.internal.`);
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`);
expect(txt).toContain(`_openclaw-gw._tcp IN PTR studio-london._openclaw-gw._tcp`);
expect(txt).toContain(`studio-london._openclaw-gw._tcp IN SRV 0 0 18789 studio-london`);
expect(txt).toContain(`displayName=Mac Studio (OpenClaw)`);
expect(txt).toContain(`gatewayPort=18789`);
expect(txt).toContain(`sshPort=22`);
expect(txt).toContain(`cliPath=/opt/homebrew/bin/openclaw`);
expectZoneRecords(txt, [
`$ORIGIN openclaw.internal.`,
`studio-london IN A 100.123.224.76`,
`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`,
`_openclaw-gw._tcp IN PTR studio-london._openclaw-gw._tcp`,
`studio-london._openclaw-gw._tcp IN SRV 0 0 18789 studio-london`,
`displayName=Mac Studio (OpenClaw)`,
`gatewayPort=18789`,
`sshPort=22`,
`cliPath=/opt/homebrew/bin/openclaw`,
]);
});
it("includes tailnetDns when provided", () => {
const txt = renderWideAreaGatewayZoneText({
domain: "openclaw.internal.",
serial: 2025121701,
gatewayPort: 18789,
displayName: "Mac Studio (OpenClaw)",
tailnetIPv4: "100.123.224.76",
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
hostLabel: "studio-london",
instanceLabel: "studio-london",
});
expect(txt).toContain(`tailnetDns=peters-mac-studio-1.sheep-coho.ts.net`);
});
it("includes gateway TLS TXT fields and trims display metadata", () => {
const txt = renderWideAreaGatewayZoneText({
domain: "openclaw.internal",
serial: 2025121701,
gatewayPort: 18789,
displayName: " Mac Studio (OpenClaw) ",
tailnetIPv4: "100.123.224.76",
hostLabel: " Studio London ",
instanceLabel: " Studio London ",
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
tailnetDns: " tailnet.ts.net ",
cliPath: " /opt/homebrew/bin/openclaw ",
});
expect(txt).toContain(`$ORIGIN openclaw.internal.`);
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
expect(txt).toContain(`studio-london._openclaw-gw._tcp IN TXT`);
expect(txt).toContain(`displayName=Mac Studio (OpenClaw)`);
expect(txt).toContain(`gatewayTls=1`);
expect(txt).toContain(`gatewayTlsSha256=abc123`);
expect(txt).toContain(`tailnetDns=tailnet.ts.net`);
expect(txt).toContain(`cliPath=/opt/homebrew/bin/openclaw`);
it.each([
{
name: "includes tailnetDns when provided",
overrides: { tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net" },
records: [`tailnetDns=peters-mac-studio-1.sheep-coho.ts.net`],
},
{
name: "includes gateway TLS TXT fields and trims display metadata",
overrides: {
domain: "openclaw.internal",
displayName: " Mac Studio (OpenClaw) ",
hostLabel: " Studio London ",
instanceLabel: " Studio London ",
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
tailnetDns: " tailnet.ts.net ",
cliPath: " /opt/homebrew/bin/openclaw ",
},
records: [
`$ORIGIN openclaw.internal.`,
`studio-london IN A 100.123.224.76`,
`studio-london._openclaw-gw._tcp IN TXT`,
`displayName=Mac Studio (OpenClaw)`,
`gatewayTls=1`,
`gatewayTlsSha256=abc123`,
`tailnetDns=tailnet.ts.net`,
`cliPath=/opt/homebrew/bin/openclaw`,
],
},
])("$name", ({ overrides, records }) => {
expectZoneRecords(renderZoneText(overrides), records);
});
});