From eef2f82986fed4eda794f03a3774902bed09ef7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 23:33:03 +0000 Subject: [PATCH] test: dedupe infra utility suites --- src/infra/abort-pattern.test.ts | 16 +- src/infra/archive-helpers.test.ts | 93 +++++-- src/infra/archive-path.test.ts | 5 +- src/infra/backup-create.test.ts | 57 ++-- src/infra/canvas-host-url.test.ts | 90 +++--- src/infra/detect-package-manager.test.ts | 62 +++-- src/infra/errors.test.ts | 43 +-- .../exec-approval-command-display.test.ts | 77 +++--- src/infra/exec-safety.test.ts | 31 +-- src/infra/format-time/format-time.test.ts | 183 ++++++------- .../parse-offsetless-zoned-datetime.test.ts | 41 +-- src/infra/gemini-auth.test.ts | 31 +-- src/infra/google-api-base-url.test.ts | 45 +-- src/infra/hardlink-guards.test.ts | 70 ++--- src/infra/install-source-utils.test.ts | 67 ++--- src/infra/json-file.test.ts | 14 +- src/infra/json-files.test.ts | 137 +++++---- src/infra/json-utf8-bytes.test.ts | 17 +- src/infra/net/proxy-env.test.ts | 119 ++++---- src/infra/net/ssrf.test.ts | 40 +-- src/infra/network-discovery-display.test.ts | 25 +- src/infra/network-interfaces.test.ts | 59 ++-- src/infra/npm-registry-spec.test.ts | 259 +++++++++--------- src/infra/openclaw-exec-env.test.ts | 13 +- src/infra/parse-finite-number.test.ts | 91 +++--- src/infra/path-alias-guards.test.ts | 116 ++++---- src/infra/path-guards.test.ts | 89 +++--- src/infra/path-prepend.test.ts | 34 ++- src/infra/ports-format.test.ts | 50 ++-- src/infra/ports-probe.test.ts | 28 +- src/infra/provider-usage.format.test.ts | 102 +++---- src/infra/provider-usage.shared.test.ts | 83 +++--- src/infra/resolve-system-bin.test.ts | 110 +++++--- src/infra/retry.test.ts | 106 ++++--- src/infra/safe-open-sync.test.ts | 21 +- src/infra/scp-host.test.ts | 68 ++--- src/infra/secret-file.test.ts | 242 +++++++++------- src/infra/secure-random.test.ts | 47 ++-- src/infra/shell-inline-command.test.ts | 114 ++++---- src/infra/system-message.test.ts | 73 +++-- src/infra/system-run-normalize.test.ts | 22 +- src/infra/widearea-dns.test.ts | 111 ++++---- 42 files changed, 1675 insertions(+), 1426 deletions(-) diff --git a/src/infra/abort-pattern.test.ts b/src/infra/abort-pattern.test.ts index 1bcb7b5bf63..b233643699a 100644 --- a/src/infra/abort-pattern.test.ts +++ b/src/infra/abort-pattern.test.ts @@ -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); }); }); diff --git a/src/infra/archive-helpers.test.ts b/src/infra/archive-helpers.test.ts index 390b4b0300e..a238d2bfcdb 100644 --- a/src/infra/archive-helpers.test.ts +++ b/src/infra/archive-helpers.test.ts @@ -14,6 +14,14 @@ import { const tempDirs = createTrackedTempDirs(); const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-"); +function expectTarPreflightError( + checker: ReturnType, + entry: Parameters>[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", ); }); diff --git a/src/infra/archive-path.test.ts b/src/infra/archive-path.test.ts index 8e1c5cb7dda..97a83a30e50 100644 --- a/src/infra/archive-path.test.ts +++ b/src/infra/archive-path.test.ts @@ -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( () => diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 5d3a38bee21..bc375270227 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -17,9 +17,12 @@ function makeResult(overrides: Partial = {}): 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); }); }); diff --git a/src/infra/canvas-host-url.test.ts b/src/infra/canvas-host-url.test.ts index 2ca7401a2bb..26ae9720e07 100644 --- a/src/infra/canvas-host-url.test.ts +++ b/src/infra/canvas-host-url.test.ts @@ -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[0])).toBe( + expected, + ); }); }); diff --git a/src/infra/detect-package-manager.test.ts b/src/infra/detect-package-manager.test.ts index 791b5894cda..f9fe65d48db 100644 --- a/src/infra/detect-package-manager.test.ts +++ b/src/infra/detect-package-manager.test.ts @@ -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 { + 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(); }); diff --git a/src/infra/errors.test.ts b/src/infra/errors.test.ts index 45b6b73e395..1102a6874cc 100644 --- a/src/infra/errors.test.ts +++ b/src/infra/errors.test.ts @@ -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", () => { diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index f89c66ccb1a..df8f0edf131 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -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); }); }); diff --git a/src/infra/exec-safety.test.ts b/src/infra/exec-safety.test.ts index 96dcdba357e..cdcb5fb7318 100644 --- a/src/infra/exec-safety.test.ts +++ b/src/infra/exec-safety.test.ts @@ -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); }); }); diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index b30c2a43135..75f8c8b649b 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -10,52 +10,52 @@ import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js"; const invalidDurationInputs = [null, undefined, -100] as const; +function expectFormatterCases( + 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); }); }); diff --git a/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts b/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts index 6136e063f2f..0caa826af5f 100644 --- a/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts +++ b/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts @@ -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); }); }); diff --git a/src/infra/gemini-auth.test.ts b/src/infra/gemini-auth.test.ts index 6e496f75ec2..c4ef114cf19 100644 --- a/src/infra/gemini-auth.test.ts +++ b/src/infra/gemini-auth.test.ts @@ -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", + }, + }); + }, + ); }); diff --git a/src/infra/google-api-base-url.test.ts b/src/infra/google-api-base-url.test.ts index 78110ab5ee2..ef32f84fa8d 100644 --- a/src/infra/google-api-base-url.test.ts +++ b/src/infra/google-api-base-url.test.ts @@ -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); }); }); diff --git a/src/infra/hardlink-guards.test.ts b/src/infra/hardlink-guards.test.ts index 1a8f7205bcb..b1981b8bd2d 100644 --- a/src/infra/hardlink-guards.test.ts +++ b/src/infra/hardlink-guards.test.ts @@ -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, +): Promise { + 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"); diff --git a/src/infra/install-source-utils.test.ts b/src/infra/install-source-utils.test.ts index 95215530bb2..f6d468b2e31 100644 --- a/src/infra/install-source-utils.test.ts +++ b/src/infra/install-source-utils.test.ts @@ -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 () => { diff --git a/src/infra/json-file.test.ts b/src/infra/json-file.test.ts index 40006bb4752..10f344d4902 100644 --- a/src/infra/json-file.test.ts +++ b/src/infra/json-file.test.ts @@ -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( + run: (params: { root: string; pathname: string }) => Promise | T, +): Promise { + 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 }); diff --git a/src/infra/json-files.test.ts b/src/infra/json-files.test.ts index dcd17aec45d..82a9686aa13 100644 --- a/src/infra/json-files.test.ts +++ b/src/infra/json-files.test.ts @@ -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(run: (base: string) => Promise): Promise { + 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); }); }); diff --git a/src/infra/json-utf8-bytes.test.ts b/src/infra/json-utf8-bytes.test.ts index 553152fff8d..20dde70841f 100644 --- a/src/infra/json-utf8-bytes.test.ts +++ b/src/infra/json-utf8-bytes.test.ts @@ -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 }) => { diff --git a/src/infra/net/proxy-env.test.ts b/src/infra/net/proxy-env.test.ts index 3f3031f028a..a7a0386ba5a 100644 --- a/src/infra/net/proxy-env.test.ts +++ b/src/infra/net/proxy-env.test.ts @@ -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); }); }); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 4591a058df7..2d0f4a7527a 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -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); }); }); diff --git a/src/infra/network-discovery-display.test.ts b/src/infra/network-discovery-display.test.ts index 19fc78ecd1d..174a286e87d 100644 --- a/src/infra/network-discovery-display.test.ts +++ b/src/infra/network-discovery-display.test.ts @@ -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}.`, }); }); diff --git a/src/infra/network-interfaces.test.ts b/src/infra/network-interfaces.test.ts index b9431351ac6..598be742c67 100644 --- a/src/infra/network-interfaces.test.ts +++ b/src/infra/network-interfaces.test.ts @@ -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) => + 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) => + expect( + pickMatchingExternalInterfaceAddress(snapshot, { + family: "IPv4", + preferredNames: ["en0", "eth0"], + }), + ).toBe("192.168.1.42"), + }, + ])("$name", ({ snapshot, run }) => { + run(snapshot); }); }); diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index fe7058dc5b7..d04df129f6a 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -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); }); }); diff --git a/src/infra/openclaw-exec-env.test.ts b/src/infra/openclaw-exec-env.test.ts index 0951757876c..67897c9d03a 100644 --- a/src/infra/openclaw-exec-env.test.ts +++ b/src/infra/openclaw-exec-env.test.ts @@ -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); }); diff --git a/src/infra/parse-finite-number.test.ts b/src/infra/parse-finite-number.test.ts index 46329f3001b..2ee69fbcac2 100644 --- a/src/infra/parse-finite-number.test.ts +++ b/src/infra/parse-finite-number.test.ts @@ -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( + 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 }, + ]); }); }); diff --git a/src/infra/path-alias-guards.test.ts b/src/infra/path-alias-guards.test.ts index 7d70b79805a..3019b97c130 100644 --- a/src/infra/path-alias-guards.test.ts +++ b/src/infra/path-alias-guards.test.ts @@ -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): Promise { + 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(); + }); + }); }); diff --git a/src/infra/path-guards.test.ts b/src/infra/path-guards.test.ts index 335d86f639e..9c2aa69edc5 100644 --- a/src/infra/path-guards.test.ts +++ b/src/infra/path-guards.test.ts @@ -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); + } }); }); diff --git a/src/infra/path-prepend.test.ts b/src/infra/path-prepend.test.ts index 876cb72b6ed..9d5f856c7dc 100644 --- a/src/infra/path-prepend.test.ts +++ b/src/infra/path-prepend.test.ts @@ -8,6 +8,7 @@ import { } from "./path-prepend.js"; const env = (value: Record) => 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); }); }); diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts index c532de63970..3ef0497174c 100644 --- a/src/infra/ports-format.test.ts +++ b/src/infra/ports-format.test.ts @@ -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", () => { diff --git a/src/infra/ports-probe.test.ts b/src/infra/ports-probe.test.ts index ce127970cce..bf614552ab1 100644 --- a/src/infra/ports-probe.test.ts +++ b/src/infra/ports-probe.test.ts @@ -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): Promise { + const server = net.createServer(); + await new Promise((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((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((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((resolve) => server.close(() => resolve())); - } + }); }); }); diff --git a/src/infra/provider-usage.format.test.ts b/src/infra/provider-usage.format.test.ts index d87d6a73c17..6659bfd1b55 100644 --- a/src/infra/provider-usage.format.test.ts +++ b/src/infra/provider-usage.format.test.ts @@ -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); }); }); diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts index 4f575f197ff..912b8b55d42 100644 --- a/src/infra/provider-usage.shared.test.ts +++ b/src/infra/provider-usage.shared.test.ts @@ -9,6 +9,21 @@ import { withTimeout, } from "./provider-usage.shared.js"; +async function withLegacyPiAuthFile( + contents: string, + run: (home: string) => Promise | void, +): Promise { + 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); + }); }); }); diff --git a/src/infra/resolve-system-bin.test.ts b/src/infra/resolve-system-bin.test.ts index a94310baf0e..ba8d04698ba 100644 --- a/src/infra/resolve-system-bin.test.ts +++ b/src/infra/resolve-system-bin.test.ts @@ -9,6 +9,24 @@ import { let executables: Set; +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(); _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", () => { diff --git a/src/infra/retry.test.ts b/src/infra/retry.test.ts index a06b7532a2b..5a8ebf2fd69 100644 --- a/src/infra/retry.test.ts +++ b/src/infra/retry.test.ts @@ -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; + 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( - fn: ReturnType Promise>>, +async function runRetryNumberCase( + fn: ReturnType, attempts: number, initialDelayMs: number, -): Promise { +): Promise { vi.clearAllTimers(); vi.useFakeTimers(); try { - const promise = retryAsync(fn, attempts, initialDelayMs); + const promise = retryAsync(fn as () => Promise, 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([ + { + 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); }); }); diff --git a/src/infra/safe-open-sync.test.ts b/src/infra/safe-open-sync.test.ts index c21a9866cd2..8b43fca869b 100644 --- a/src/infra/safe-open-sync.test.ts +++ b/src/infra/safe-open-sync.test.ts @@ -65,6 +65,17 @@ async function expectOpenFailure(params: { }); } +function expectOpenReason( + opened: ReturnType, + 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"); }); }); diff --git a/src/infra/scp-host.test.ts b/src/infra/scp-host.test.ts index ab8517b5f69..7ba66859b31 100644 --- a/src/infra/scp-host.test.ts +++ b/src/infra/scp-host.test.ts @@ -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/badpath.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/badpath.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); }); }); diff --git a/src/infra/secret-file.test.ts b/src/infra/secret-file.test.ts index 5e9e6fe7b90..b2c4ebdffe3 100644 --- a/src/infra/secret-file.test.ts +++ b/src/infra/secret-file.test.ts @@ -16,6 +16,24 @@ afterEach(async () => { await tempDirs.cleanup(); }); +async function expectSecretFileError(params: { + setup: (dir: string) => Promise; + expectedMessage: (file: string) => string; + secretLabel?: string; + options?: Parameters[2]; +}): Promise { + 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): Promise { + 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)(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)()); }); }); diff --git a/src/infra/secure-random.test.ts b/src/infra/secure-random.test.ts index 1c9f8d949bc..938b2ba95c0 100644 --- a/src/infra/secure-random.test.ts +++ b/src/infra/secure-random.test.ts @@ -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_-]*$/); }); }); diff --git a/src/infra/shell-inline-command.test.ts b/src/infra/shell-inline-command.test.ts index 1c5892eff59..4cea7c67c43 100644 --- a/src/infra/shell-inline-command.test.ts +++ b/src/infra/shell-inline-command.test.ts @@ -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 --", () => { diff --git a/src/infra/system-message.test.ts b/src/infra/system-message.test.ts index 980c852eeb4..3c761dfc03f 100644 --- a/src/infra/system-message.test.ts +++ b/src/infra/system-message.test.ts @@ -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); }); }); diff --git a/src/infra/system-run-normalize.test.ts b/src/infra/system-run-normalize.test.ts index 6bf2f56d4e9..0116ec70873 100644 --- a/src/infra/system-run-normalize.test.ts +++ b/src/infra/system-run-normalize.test.ts @@ -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); }); }); diff --git a/src/infra/widearea-dns.test.ts b/src/infra/widearea-dns.test.ts index f2ab0c0f54f..b832d113b68 100644 --- a/src/infra/widearea-dns.test.ts +++ b/src/infra/widearea-dns.test.ts @@ -24,6 +24,19 @@ function makeZoneOpts(overrides: Partial = {}): WideAre return { ...baseZoneOpts, ...overrides }; } +function renderZoneText(overrides: Partial = {}): 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); }); });