mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
test: dedupe infra utility suites
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-");
|
||||
|
||||
function expectTarPreflightError(
|
||||
checker: ReturnType<typeof createTarEntryPreflightChecker>,
|
||||
entry: Parameters<ReturnType<typeof createTarEntryPreflightChecker>>[0],
|
||||
expected: string | RegExp,
|
||||
): void {
|
||||
expect(() => checker(entry)).toThrow(expected);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await tempDirs.cleanup();
|
||||
@@ -30,32 +38,53 @@ describe("archive helpers", () => {
|
||||
expect(resolveArchiveKind(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("resolves packed roots from package dir or single extracted root dir", async () => {
|
||||
const directDir = await createTempDir();
|
||||
const fallbackDir = await createTempDir();
|
||||
const markerDir = await createTempDir();
|
||||
await fs.mkdir(path.join(directDir, "package"), { recursive: true });
|
||||
await fs.mkdir(path.join(fallbackDir, "bundle-root"), { recursive: true });
|
||||
await fs.writeFile(path.join(markerDir, "package.json"), "{}", "utf8");
|
||||
|
||||
await expect(resolvePackedRootDir(directDir)).resolves.toBe(path.join(directDir, "package"));
|
||||
await expect(resolvePackedRootDir(fallbackDir)).resolves.toBe(
|
||||
path.join(fallbackDir, "bundle-root"),
|
||||
);
|
||||
await expect(resolvePackedRootDir(markerDir, { rootMarkers: ["package.json"] })).resolves.toBe(
|
||||
markerDir,
|
||||
);
|
||||
it.each([
|
||||
{
|
||||
name: "uses the package directory when present",
|
||||
setup: async (root: string) => {
|
||||
await fs.mkdir(path.join(root, "package"), { recursive: true });
|
||||
},
|
||||
expected: (root: string) => path.join(root, "package"),
|
||||
},
|
||||
{
|
||||
name: "uses the single extracted root directory as a fallback",
|
||||
setup: async (root: string) => {
|
||||
await fs.mkdir(path.join(root, "bundle-root"), { recursive: true });
|
||||
},
|
||||
expected: (root: string) => path.join(root, "bundle-root"),
|
||||
},
|
||||
{
|
||||
name: "uses the extraction root when a root marker is present",
|
||||
setup: async (root: string) => {
|
||||
await fs.writeFile(path.join(root, "package.json"), "{}", "utf8");
|
||||
},
|
||||
opts: { rootMarkers: ["package.json"] },
|
||||
expected: (root: string) => root,
|
||||
},
|
||||
])("resolves packed roots when $name", async ({ setup, expected, opts }) => {
|
||||
const root = await createTempDir();
|
||||
await setup(root);
|
||||
await expect(resolvePackedRootDir(root, opts)).resolves.toBe(expected(root));
|
||||
});
|
||||
|
||||
it("rejects unexpected packed root layouts", async () => {
|
||||
const multipleDir = await createTempDir();
|
||||
const emptyDir = await createTempDir();
|
||||
await fs.mkdir(path.join(multipleDir, "a"), { recursive: true });
|
||||
await fs.mkdir(path.join(multipleDir, "b"), { recursive: true });
|
||||
await fs.writeFile(path.join(emptyDir, "note.txt"), "hi", "utf8");
|
||||
|
||||
await expect(resolvePackedRootDir(multipleDir)).rejects.toThrow(/unexpected archive layout/i);
|
||||
await expect(resolvePackedRootDir(emptyDir)).rejects.toThrow(/unexpected archive layout/i);
|
||||
it.each([
|
||||
{
|
||||
name: "multiple extracted roots exist",
|
||||
setup: async (root: string) => {
|
||||
await fs.mkdir(path.join(root, "a"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "b"), { recursive: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only non-root marker files exist",
|
||||
setup: async (root: string) => {
|
||||
await fs.writeFile(path.join(root, "note.txt"), "hi", "utf8");
|
||||
},
|
||||
},
|
||||
])("rejects unexpected packed root layouts when $name", async ({ setup }) => {
|
||||
const root = await createTempDir();
|
||||
await setup(root);
|
||||
await expect(resolvePackedRootDir(root)).rejects.toThrow(/unexpected archive layout/i);
|
||||
});
|
||||
|
||||
it("returns work results and propagates errors before timeout", async () => {
|
||||
@@ -84,15 +113,21 @@ describe("archive helpers", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => checker({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow(
|
||||
expectTarPreflightError(
|
||||
checker,
|
||||
{ path: "package/link", type: "SymbolicLink", size: 0 },
|
||||
"tar entry is a link: package/link",
|
||||
);
|
||||
expect(() => checker({ path: "../escape.txt", type: "File", size: 1 })).toThrow(
|
||||
expectTarPreflightError(
|
||||
checker,
|
||||
{ path: "../escape.txt", type: "File", size: 1 },
|
||||
/escapes destination|absolute/i,
|
||||
);
|
||||
|
||||
checker({ path: "package/ok.txt", type: "File", size: 8 });
|
||||
expect(() => checker({ path: "package/second.txt", type: "File", size: 1 })).toThrow(
|
||||
expectTarPreflightError(
|
||||
checker,
|
||||
{ path: "package/second.txt", type: "File", size: 1 },
|
||||
"archive entry count exceeds limit",
|
||||
);
|
||||
});
|
||||
@@ -110,7 +145,9 @@ describe("archive helpers", () => {
|
||||
|
||||
expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow();
|
||||
checker({ path: "package/a.txt", type: "File", size: 6 });
|
||||
expect(() => checker({ path: "package/b.txt", type: "File", size: 6 })).toThrow(
|
||||
expectTarPreflightError(
|
||||
checker,
|
||||
{ path: "package/b.txt", type: "File", size: 6 },
|
||||
"archive extracted size exceeds limit",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -17,9 +17,12 @@ function makeResult(overrides: Partial<BackupCreateResult> = {}): BackupCreateRe
|
||||
}
|
||||
|
||||
describe("formatBackupCreateSummary", () => {
|
||||
it("formats created archives with included and skipped paths", () => {
|
||||
const lines = formatBackupCreateSummary(
|
||||
makeResult({
|
||||
const backupArchiveLine = "Backup archive: /tmp/openclaw-backup.tar.gz";
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "formats created archives with included and skipped paths",
|
||||
result: makeResult({
|
||||
verified: true,
|
||||
assets: [
|
||||
{
|
||||
@@ -39,22 +42,19 @@ describe("formatBackupCreateSummary", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lines).toEqual([
|
||||
"Backup archive: /tmp/openclaw-backup.tar.gz",
|
||||
"Included 1 path:",
|
||||
"- state: ~/.openclaw",
|
||||
"Skipped 1 path:",
|
||||
"- workspace: ~/Projects/openclaw (covered by ~/.openclaw)",
|
||||
"Created /tmp/openclaw-backup.tar.gz",
|
||||
"Archive verification: passed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats dry runs and pluralized counts", () => {
|
||||
const lines = formatBackupCreateSummary(
|
||||
makeResult({
|
||||
expected: [
|
||||
backupArchiveLine,
|
||||
"Included 1 path:",
|
||||
"- state: ~/.openclaw",
|
||||
"Skipped 1 path:",
|
||||
"- workspace: ~/Projects/openclaw (covered by ~/.openclaw)",
|
||||
"Created /tmp/openclaw-backup.tar.gz",
|
||||
"Archive verification: passed",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "formats dry runs and pluralized counts",
|
||||
result: makeResult({
|
||||
dryRun: true,
|
||||
assets: [
|
||||
{
|
||||
@@ -71,14 +71,15 @@ describe("formatBackupCreateSummary", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lines).toEqual([
|
||||
"Backup archive: /tmp/openclaw-backup.tar.gz",
|
||||
"Included 2 paths:",
|
||||
"- config: ~/.openclaw/config.json",
|
||||
"- credentials: ~/.openclaw/oauth",
|
||||
"Dry run only; archive was not written.",
|
||||
]);
|
||||
expected: [
|
||||
backupArchiveLine,
|
||||
"Included 2 paths:",
|
||||
"- config: ~/.openclaw/config.json",
|
||||
"- credentials: ~/.openclaw/oauth",
|
||||
"Dry run only; archive was not written.",
|
||||
],
|
||||
},
|
||||
])("$name", ({ result, expected }) => {
|
||||
expect(formatBackupCreateSummary(result)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,63 +2,75 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveCanvasHostUrl } from "./canvas-host-url.js";
|
||||
|
||||
describe("resolveCanvasHostUrl", () => {
|
||||
it("returns undefined when no canvas port or usable host is available", () => {
|
||||
expect(resolveCanvasHostUrl({})).toBeUndefined();
|
||||
expect(resolveCanvasHostUrl({ canvasPort: 3000, hostOverride: "127.0.0.1" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers non-loopback host overrides and preserves explicit ports", () => {
|
||||
expect(
|
||||
resolveCanvasHostUrl({
|
||||
it.each([
|
||||
{
|
||||
name: "returns undefined when no canvas port is available",
|
||||
params: {},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined when only a loopback host override is available",
|
||||
params: { canvasPort: 3000, hostOverride: "127.0.0.1" },
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "prefers non-loopback host overrides and preserves explicit ports",
|
||||
params: {
|
||||
canvasPort: 3000,
|
||||
hostOverride: " canvas.openclaw.ai ",
|
||||
requestHost: "gateway.local:9000",
|
||||
localAddress: "192.168.1.10",
|
||||
}),
|
||||
).toBe("http://canvas.openclaw.ai:3000");
|
||||
});
|
||||
|
||||
it("falls back from rejected loopback overrides to request hosts", () => {
|
||||
expect(
|
||||
resolveCanvasHostUrl({
|
||||
},
|
||||
expected: "http://canvas.openclaw.ai:3000",
|
||||
},
|
||||
{
|
||||
name: "falls back from rejected loopback overrides to request hosts",
|
||||
params: {
|
||||
canvasPort: 3000,
|
||||
hostOverride: "127.0.0.1",
|
||||
requestHost: "example.com:8443",
|
||||
}),
|
||||
).toBe("http://example.com:3000");
|
||||
});
|
||||
|
||||
it("maps proxied default gateway ports to request-host ports or scheme defaults", () => {
|
||||
expect(
|
||||
resolveCanvasHostUrl({
|
||||
},
|
||||
expected: "http://example.com:3000",
|
||||
},
|
||||
{
|
||||
name: "maps proxied default gateway ports to request-host ports",
|
||||
params: {
|
||||
canvasPort: 18789,
|
||||
requestHost: "gateway.example.com:9443",
|
||||
forwardedProto: "https",
|
||||
}),
|
||||
).toBe("https://gateway.example.com:9443");
|
||||
expect(
|
||||
resolveCanvasHostUrl({
|
||||
},
|
||||
expected: "https://gateway.example.com:9443",
|
||||
},
|
||||
{
|
||||
name: "maps proxied default gateway ports to scheme defaults",
|
||||
params: {
|
||||
canvasPort: 18789,
|
||||
requestHost: "gateway.example.com",
|
||||
forwardedProto: ["https", "http"],
|
||||
}),
|
||||
).toBe("https://gateway.example.com:443");
|
||||
expect(
|
||||
resolveCanvasHostUrl({
|
||||
},
|
||||
expected: "https://gateway.example.com:443",
|
||||
},
|
||||
{
|
||||
name: "uses http scheme defaults without forwarded proto",
|
||||
params: {
|
||||
canvasPort: 18789,
|
||||
requestHost: "gateway.example.com",
|
||||
}),
|
||||
).toBe("http://gateway.example.com:80");
|
||||
});
|
||||
|
||||
it("brackets ipv6 hosts and can fall back to local addresses", () => {
|
||||
expect(
|
||||
resolveCanvasHostUrl({
|
||||
},
|
||||
expected: "http://gateway.example.com:80",
|
||||
},
|
||||
{
|
||||
name: "brackets ipv6 hosts and can fall back to local addresses",
|
||||
params: {
|
||||
canvasPort: 3000,
|
||||
requestHost: "not a host",
|
||||
localAddress: "2001:db8::1",
|
||||
scheme: "https",
|
||||
}),
|
||||
).toBe("https://[2001:db8::1]:3000");
|
||||
},
|
||||
expected: "https://[2001:db8::1]:3000",
|
||||
},
|
||||
])("$name", ({ params, expected }) => {
|
||||
expect(resolveCanvasHostUrl(params as Parameters<typeof resolveCanvasHostUrl>[0])).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,41 +4,53 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectPackageManager } from "./detect-package-manager.js";
|
||||
|
||||
async function createPackageManagerRoot(
|
||||
files: Array<{ path: string; content: string }>,
|
||||
): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
|
||||
for (const file of files) {
|
||||
await fs.writeFile(path.join(root, file.path), file.content, "utf8");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("detectPackageManager", () => {
|
||||
it("prefers packageManager from package.json when supported", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ packageManager: "pnpm@10.8.1" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(root, "package-lock.json"), "", "utf8");
|
||||
const root = await createPackageManagerRoot([
|
||||
{ path: "package.json", content: JSON.stringify({ packageManager: "pnpm@10.8.1" }) },
|
||||
{ path: "package-lock.json", content: "" },
|
||||
]);
|
||||
|
||||
await expect(detectPackageManager(root)).resolves.toBe("pnpm");
|
||||
});
|
||||
|
||||
it("falls back to lockfiles when package.json is missing or unsupported", async () => {
|
||||
const bunRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
|
||||
await fs.writeFile(path.join(bunRoot, "bun.lock"), "", "utf8");
|
||||
await expect(detectPackageManager(bunRoot)).resolves.toBe("bun");
|
||||
|
||||
const legacyBunRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
|
||||
await fs.writeFile(path.join(legacyBunRoot, "bun.lockb"), "", "utf8");
|
||||
await expect(detectPackageManager(legacyBunRoot)).resolves.toBe("bun");
|
||||
|
||||
const npmRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
JSON.stringify({ packageManager: "yarn@4.0.0" }),
|
||||
"utf8",
|
||||
it.each([
|
||||
{
|
||||
name: "uses bun.lock",
|
||||
files: [{ path: "bun.lock", content: "" }],
|
||||
expected: "bun",
|
||||
},
|
||||
{
|
||||
name: "uses bun.lockb",
|
||||
files: [{ path: "bun.lockb", content: "" }],
|
||||
expected: "bun",
|
||||
},
|
||||
{
|
||||
name: "falls back to npm lockfiles for unsupported packageManager values",
|
||||
files: [
|
||||
{ path: "package.json", content: JSON.stringify({ packageManager: "yarn@4.0.0" }) },
|
||||
{ path: "package-lock.json", content: "" },
|
||||
],
|
||||
expected: "npm",
|
||||
},
|
||||
])("falls back to lockfiles when $name", async ({ files, expected }) => {
|
||||
await expect(detectPackageManager(await createPackageManagerRoot(files))).resolves.toBe(
|
||||
expected,
|
||||
);
|
||||
await fs.writeFile(path.join(npmRoot, "package-lock.json"), "", "utf8");
|
||||
await expect(detectPackageManager(npmRoot)).resolves.toBe("npm");
|
||||
});
|
||||
|
||||
it("returns null when no package manager markers exist", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-detect-pm-"));
|
||||
await fs.writeFile(path.join(root, "package.json"), "{not-json}", "utf8");
|
||||
const root = await createPackageManagerRoot([{ path: "package.json", content: "{not-json}" }]);
|
||||
|
||||
await expect(detectPackageManager(root)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,52 +10,52 @@ import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js";
|
||||
|
||||
const invalidDurationInputs = [null, undefined, -100] as const;
|
||||
|
||||
function expectFormatterCases<TInput, TOutput>(
|
||||
formatter: (value: TInput) => TOutput,
|
||||
cases: ReadonlyArray<{ input: TInput; expected: TOutput }>,
|
||||
) {
|
||||
for (const { input, expected } of cases) {
|
||||
expect(formatter(input), String(input)).toBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("format-duration", () => {
|
||||
describe("formatDurationCompact", () => {
|
||||
it("returns undefined for null/undefined/non-positive", () => {
|
||||
expect(formatDurationCompact(null)).toBeUndefined();
|
||||
expect(formatDurationCompact(undefined)).toBeUndefined();
|
||||
expect(formatDurationCompact(0)).toBeUndefined();
|
||||
expect(formatDurationCompact(-100)).toBeUndefined();
|
||||
it.each([null, undefined, 0, -100])("returns undefined for %j", (value) => {
|
||||
expect(formatDurationCompact(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats compact units and omits trailing zero components", () => {
|
||||
const cases = [
|
||||
[500, "500ms"],
|
||||
[999, "999ms"],
|
||||
[1000, "1s"],
|
||||
[45000, "45s"],
|
||||
[59000, "59s"],
|
||||
[60000, "1m"], // not "1m0s"
|
||||
[65000, "1m5s"],
|
||||
[90000, "1m30s"],
|
||||
[3600000, "1h"], // not "1h0m"
|
||||
[3660000, "1h1m"],
|
||||
[5400000, "1h30m"],
|
||||
[86400000, "1d"], // not "1d0h"
|
||||
[90000000, "1d1h"],
|
||||
[172800000, "2d"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(formatDurationCompact(input), String(input)).toBe(expected);
|
||||
}
|
||||
expectFormatterCases(formatDurationCompact, [
|
||||
{ input: 500, expected: "500ms" },
|
||||
{ input: 999, expected: "999ms" },
|
||||
{ input: 1000, expected: "1s" },
|
||||
{ input: 45000, expected: "45s" },
|
||||
{ input: 59000, expected: "59s" },
|
||||
{ input: 60000, expected: "1m" },
|
||||
{ input: 65000, expected: "1m5s" },
|
||||
{ input: 90000, expected: "1m30s" },
|
||||
{ input: 3600000, expected: "1h" },
|
||||
{ input: 3660000, expected: "1h1m" },
|
||||
{ input: 5400000, expected: "1h30m" },
|
||||
{ input: 86400000, expected: "1d" },
|
||||
{ input: 90000000, expected: "1d1h" },
|
||||
{ input: 172800000, expected: "2d" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports spaced option", () => {
|
||||
expect(formatDurationCompact(65000, { spaced: true })).toBe("1m 5s");
|
||||
expect(formatDurationCompact(3660000, { spaced: true })).toBe("1h 1m");
|
||||
expect(formatDurationCompact(90000000, { spaced: true })).toBe("1d 1h");
|
||||
});
|
||||
|
||||
it("rounds at boundaries", () => {
|
||||
// 59.5 seconds rounds to 60s = 1m
|
||||
expect(formatDurationCompact(59500)).toBe("1m");
|
||||
// 59.4 seconds rounds to 59s
|
||||
expect(formatDurationCompact(59400)).toBe("59s");
|
||||
it.each([
|
||||
{ input: 65000, options: { spaced: true }, expected: "1m 5s" },
|
||||
{ input: 3660000, options: { spaced: true }, expected: "1h 1m" },
|
||||
{ input: 90000000, options: { spaced: true }, expected: "1d 1h" },
|
||||
{ input: 59500, expected: "1m" },
|
||||
{ input: 59400, expected: "59s" },
|
||||
])("formats compact duration for %j", ({ input, options, expected }) => {
|
||||
expect(formatDurationCompact(input, options)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,61 +68,47 @@ describe("format-duration", () => {
|
||||
});
|
||||
|
||||
it("formats single-unit outputs and day threshold behavior", () => {
|
||||
const cases = [
|
||||
[500, "500ms"],
|
||||
[5000, "5s"],
|
||||
[180000, "3m"],
|
||||
[7200000, "2h"],
|
||||
[23 * 3600000, "23h"],
|
||||
[24 * 3600000, "1d"],
|
||||
[25 * 3600000, "1d"], // rounds
|
||||
[172800000, "2d"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(formatDurationHuman(input), String(input)).toBe(expected);
|
||||
}
|
||||
expectFormatterCases(formatDurationHuman, [
|
||||
{ input: 500, expected: "500ms" },
|
||||
{ input: 5000, expected: "5s" },
|
||||
{ input: 180000, expected: "3m" },
|
||||
{ input: 7200000, expected: "2h" },
|
||||
{ input: 23 * 3600000, expected: "23h" },
|
||||
{ input: 24 * 3600000, expected: "1d" },
|
||||
{ input: 25 * 3600000, expected: "1d" },
|
||||
{ input: 172800000, expected: "2d" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationPrecise", () => {
|
||||
it("shows milliseconds for sub-second", () => {
|
||||
expect(formatDurationPrecise(500)).toBe("500ms");
|
||||
expect(formatDurationPrecise(999)).toBe("999ms");
|
||||
});
|
||||
|
||||
it("clamps negative and fractional sub-second values to non-negative milliseconds", () => {
|
||||
expect(formatDurationPrecise(-1)).toBe("0ms");
|
||||
expect(formatDurationPrecise(-500)).toBe("0ms");
|
||||
expect(formatDurationPrecise(999.6)).toBe("1000ms");
|
||||
});
|
||||
|
||||
it("shows decimal seconds for >=1s", () => {
|
||||
expect(formatDurationPrecise(1000)).toBe("1s");
|
||||
expect(formatDurationPrecise(1500)).toBe("1.5s");
|
||||
expect(formatDurationPrecise(1234)).toBe("1.23s");
|
||||
});
|
||||
|
||||
it("returns unknown for non-finite", () => {
|
||||
expect(formatDurationPrecise(NaN)).toBe("unknown");
|
||||
expect(formatDurationPrecise(Infinity)).toBe("unknown");
|
||||
it.each([
|
||||
{ input: 500, expected: "500ms" },
|
||||
{ input: 999, expected: "999ms" },
|
||||
{ input: -1, expected: "0ms" },
|
||||
{ input: -500, expected: "0ms" },
|
||||
{ input: 999.6, expected: "1000ms" },
|
||||
{ input: 1000, expected: "1s" },
|
||||
{ input: 1500, expected: "1.5s" },
|
||||
{ input: 1234, expected: "1.23s" },
|
||||
{ input: NaN, expected: "unknown" },
|
||||
{ input: Infinity, expected: "unknown" },
|
||||
])("formats precise duration for %j", ({ input, expected }) => {
|
||||
expect(formatDurationPrecise(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationSeconds", () => {
|
||||
it("formats with configurable decimals", () => {
|
||||
expect(formatDurationSeconds(1500, { decimals: 1 })).toBe("1.5s");
|
||||
expect(formatDurationSeconds(1234, { decimals: 2 })).toBe("1.23s");
|
||||
expect(formatDurationSeconds(1000, { decimals: 0 })).toBe("1s");
|
||||
});
|
||||
|
||||
it("supports seconds unit", () => {
|
||||
expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds");
|
||||
});
|
||||
|
||||
it("clamps negative values and rejects non-finite input", () => {
|
||||
expect(formatDurationSeconds(-1500, { decimals: 1 })).toBe("0s");
|
||||
expect(formatDurationSeconds(NaN)).toBe("unknown");
|
||||
expect(formatDurationSeconds(Infinity)).toBe("unknown");
|
||||
it.each([
|
||||
{ input: 1500, options: { decimals: 1 }, expected: "1.5s" },
|
||||
{ input: 1234, options: { decimals: 2 }, expected: "1.23s" },
|
||||
{ input: 1000, options: { decimals: 0 }, expected: "1s" },
|
||||
{ input: 2000, options: { unit: "seconds" as const }, expected: "2 seconds" },
|
||||
{ input: -1500, options: { decimals: 1 }, expected: "0s" },
|
||||
{ input: NaN, options: undefined, expected: "unknown" },
|
||||
{ input: Infinity, options: undefined, expected: "unknown" },
|
||||
])("formats seconds duration for %j", ({ input, options, expected }) => {
|
||||
expect(formatDurationSeconds(input, options)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -222,25 +208,24 @@ describe("format-relative", () => {
|
||||
});
|
||||
|
||||
it("formats relative age around key unit boundaries", () => {
|
||||
const cases = [
|
||||
[0, "just now"],
|
||||
[29000, "just now"], // rounds to <1m
|
||||
[30000, "1m ago"], // 30s rounds to 1m
|
||||
[300000, "5m ago"],
|
||||
[7200000, "2h ago"],
|
||||
[47 * 3600000, "47h ago"],
|
||||
[48 * 3600000, "2d ago"],
|
||||
[172800000, "2d ago"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(formatTimeAgo(input), String(input)).toBe(expected);
|
||||
}
|
||||
expectFormatterCases(formatTimeAgo, [
|
||||
{ input: 0, expected: "just now" },
|
||||
{ input: 29000, expected: "just now" },
|
||||
{ input: 30000, expected: "1m ago" },
|
||||
{ input: 300000, expected: "5m ago" },
|
||||
{ input: 7200000, expected: "2h ago" },
|
||||
{ input: 47 * 3600000, expected: "47h ago" },
|
||||
{ input: 48 * 3600000, expected: "2d ago" },
|
||||
{ input: 172800000, expected: "2d ago" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits suffix when suffix: false", () => {
|
||||
expect(formatTimeAgo(0, { suffix: false })).toBe("0s");
|
||||
expect(formatTimeAgo(300000, { suffix: false })).toBe("5m");
|
||||
expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h");
|
||||
it.each([
|
||||
{ input: 0, expected: "0s" },
|
||||
{ input: 300000, expected: "5m" },
|
||||
{ input: 7200000, expected: "2h" },
|
||||
])("omits suffix for %j when disabled", ({ input, expected }) => {
|
||||
expect(formatTimeAgo(input, { suffix: false })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,50 +5,52 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js";
|
||||
|
||||
async function withHardlinkFixture(
|
||||
cb: (context: { root: string; source: string; linked: string; dirPath: string }) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => {
|
||||
const dirPath = path.join(root, "dir");
|
||||
const source = path.join(root, "source.txt");
|
||||
const linked = path.join(root, "linked.txt");
|
||||
await fs.mkdir(dirPath);
|
||||
await fs.writeFile(source, "hello", "utf8");
|
||||
await fs.link(source, linked);
|
||||
await cb({ root, source, linked, dirPath });
|
||||
});
|
||||
}
|
||||
|
||||
describe("assertNoHardlinkedFinalPath", () => {
|
||||
it("allows missing paths, directories, and explicit unlink opt-in", async () => {
|
||||
await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => {
|
||||
const dirPath = path.join(root, "dir");
|
||||
await fs.mkdir(dirPath);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "allows missing paths",
|
||||
filePath: ({ root }: { root: string }) => path.join(root, "missing.txt"),
|
||||
opts: {},
|
||||
},
|
||||
{
|
||||
name: "allows directories",
|
||||
filePath: ({ dirPath }: { dirPath: string }) => dirPath,
|
||||
opts: {},
|
||||
},
|
||||
{
|
||||
name: "allows explicit unlink opt-in",
|
||||
filePath: ({ linked }: { linked: string }) => linked,
|
||||
opts: { allowFinalHardlinkForUnlink: true },
|
||||
},
|
||||
])("$name", async ({ filePath, opts }) => {
|
||||
await withHardlinkFixture(async (context) => {
|
||||
await expect(
|
||||
assertNoHardlinkedFinalPath({
|
||||
filePath: path.join(root, "missing.txt"),
|
||||
root,
|
||||
filePath: filePath(context),
|
||||
root: context.root,
|
||||
boundaryLabel: "workspace",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
assertNoHardlinkedFinalPath({
|
||||
filePath: dirPath,
|
||||
root,
|
||||
boundaryLabel: "workspace",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const source = path.join(root, "source.txt");
|
||||
const linked = path.join(root, "linked.txt");
|
||||
await fs.writeFile(source, "hello", "utf8");
|
||||
await fs.link(source, linked);
|
||||
|
||||
await expect(
|
||||
assertNoHardlinkedFinalPath({
|
||||
filePath: linked,
|
||||
root,
|
||||
boundaryLabel: "workspace",
|
||||
allowFinalHardlinkForUnlink: true,
|
||||
...opts,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hardlinked files and shortens home-relative paths in the error", async () => {
|
||||
await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => {
|
||||
const source = path.join(root, "source.txt");
|
||||
const linked = path.join(root, "linked.txt");
|
||||
await fs.writeFile(source, "hello", "utf8");
|
||||
await fs.link(source, linked);
|
||||
await withHardlinkFixture(async ({ root, linked }) => {
|
||||
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root);
|
||||
const expectedLinkedPath = path.join("~", "linked.txt");
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { loadJsonFile, saveJsonFile } from "./json-file.js";
|
||||
|
||||
async function withJsonPath<T>(
|
||||
run: (params: { root: string; pathname: string }) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
return withTempDir({ prefix: "openclaw-json-file-" }, async (root) =>
|
||||
run({ root, pathname: path.join(root, "config.json") }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("json-file helpers", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -23,8 +31,7 @@ describe("json-file helpers", () => {
|
||||
},
|
||||
},
|
||||
])("returns undefined for $name", async ({ setup }) => {
|
||||
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
|
||||
const pathname = path.join(root, "config.json");
|
||||
await withJsonPath(({ pathname }) => {
|
||||
setup(pathname);
|
||||
expect(loadJsonFile(pathname)).toBeUndefined();
|
||||
});
|
||||
@@ -62,8 +69,7 @@ describe("json-file helpers", () => {
|
||||
},
|
||||
},
|
||||
])("writes the latest payload for $name", async ({ setup }) => {
|
||||
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
|
||||
const pathname = path.join(root, "config.json");
|
||||
await withJsonPath(({ pathname }) => {
|
||||
setup(pathname);
|
||||
saveJsonFile(pathname, { enabled: true, count: 2 });
|
||||
expect(loadJsonFile(pathname)).toEqual({ enabled: true, count: 2 });
|
||||
|
||||
@@ -5,56 +5,95 @@ import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAsyncLock, readJsonFile, writeJsonAtomic, writeTextAtomic } from "./json-files.js";
|
||||
|
||||
async function withTempBase<T>(run: (base: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
|
||||
return run(base);
|
||||
}
|
||||
|
||||
describe("json file helpers", () => {
|
||||
it("reads valid json and returns null for missing or invalid files", async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
|
||||
const validPath = path.join(base, "valid.json");
|
||||
const invalidPath = path.join(base, "invalid.json");
|
||||
|
||||
await fs.writeFile(validPath, '{"ok":true}', "utf8");
|
||||
await fs.writeFile(invalidPath, "{not-json}", "utf8");
|
||||
|
||||
await expect(readJsonFile<{ ok: boolean }>(validPath)).resolves.toEqual({ ok: true });
|
||||
await expect(readJsonFile(invalidPath)).resolves.toBeNull();
|
||||
await expect(readJsonFile(path.join(base, "missing.json"))).resolves.toBeNull();
|
||||
it.each([
|
||||
{
|
||||
name: "reads valid json",
|
||||
setup: async (base: string) => {
|
||||
const filePath = path.join(base, "valid.json");
|
||||
await fs.writeFile(filePath, '{"ok":true}', "utf8");
|
||||
return filePath;
|
||||
},
|
||||
expected: { ok: true },
|
||||
},
|
||||
{
|
||||
name: "returns null for invalid files",
|
||||
setup: async (base: string) => {
|
||||
const filePath = path.join(base, "invalid.json");
|
||||
await fs.writeFile(filePath, "{not-json}", "utf8");
|
||||
return filePath;
|
||||
},
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
name: "returns null for missing files",
|
||||
setup: async (base: string) => path.join(base, "missing.json"),
|
||||
expected: null,
|
||||
},
|
||||
])("$name", async ({ setup, expected }) => {
|
||||
await withTempBase(async (base) => {
|
||||
await expect(readJsonFile(await setup(base))).resolves.toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("writes json atomically with pretty formatting and optional trailing newline", async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
|
||||
const filePath = path.join(base, "nested", "config.json");
|
||||
await withTempBase(async (base) => {
|
||||
const filePath = path.join(base, "nested", "config.json");
|
||||
|
||||
await writeJsonAtomic(
|
||||
filePath,
|
||||
{ ok: true, nested: { value: 1 } },
|
||||
{ trailingNewline: true, ensureDirMode: 0o755 },
|
||||
);
|
||||
await writeJsonAtomic(
|
||||
filePath,
|
||||
{ ok: true, nested: { value: 1 } },
|
||||
{ trailingNewline: true, ensureDirMode: 0o755 },
|
||||
);
|
||||
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe(
|
||||
'{\n "ok": true,\n "nested": {\n "value": 1\n }\n}\n',
|
||||
);
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe(
|
||||
'{\n "ok": true,\n "nested": {\n "value": 1\n }\n}\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("writes text atomically and avoids duplicate trailing newlines", async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-json-files-"));
|
||||
const filePath = path.join(base, "nested", "note.txt");
|
||||
|
||||
await writeTextAtomic(filePath, "hello", { appendTrailingNewline: true });
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("hello\n");
|
||||
|
||||
await writeTextAtomic(filePath, "hello\n", { appendTrailingNewline: true });
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("hello\n");
|
||||
it.each([
|
||||
{ input: "hello", expected: "hello\n" },
|
||||
{ input: "hello\n", expected: "hello\n" },
|
||||
])("writes text atomically for %j", async ({ input, expected }) => {
|
||||
await withTempBase(async (base) => {
|
||||
const filePath = path.join(base, "nested", "note.txt");
|
||||
await writeTextAtomic(filePath, input, { appendTrailingNewline: true });
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes async lock callers even across rejections", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "serializes async lock callers even across rejections",
|
||||
firstTask: async (events: string[]) => {
|
||||
events.push("first:start");
|
||||
await sleep(20);
|
||||
events.push("first:end");
|
||||
throw new Error("boom");
|
||||
},
|
||||
expectedFirstError: "boom",
|
||||
expectedEvents: ["first:start", "first:end", "second:start", "second:end"],
|
||||
},
|
||||
{
|
||||
name: "releases the async lock after synchronous throws",
|
||||
firstTask: async (events: string[]) => {
|
||||
events.push("first:start");
|
||||
throw new Error("sync boom");
|
||||
},
|
||||
expectedFirstError: "sync boom",
|
||||
expectedEvents: ["first:start", "second:start", "second:end"],
|
||||
},
|
||||
])("$name", async ({ firstTask, expectedFirstError, expectedEvents }) => {
|
||||
const withLock = createAsyncLock();
|
||||
const events: string[] = [];
|
||||
|
||||
const first = withLock(async () => {
|
||||
events.push("first:start");
|
||||
await sleep(20);
|
||||
events.push("first:end");
|
||||
throw new Error("boom");
|
||||
});
|
||||
const first = withLock(() => firstTask(events));
|
||||
|
||||
const second = withLock(async () => {
|
||||
events.push("second:start");
|
||||
@@ -62,28 +101,8 @@ describe("json file helpers", () => {
|
||||
return "ok";
|
||||
});
|
||||
|
||||
await expect(first).rejects.toThrow("boom");
|
||||
await expect(first).rejects.toThrow(expectedFirstError);
|
||||
await expect(second).resolves.toBe("ok");
|
||||
expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]);
|
||||
});
|
||||
|
||||
it("releases the async lock after synchronous throws", async () => {
|
||||
const withLock = createAsyncLock();
|
||||
const events: string[] = [];
|
||||
|
||||
const first = withLock(async () => {
|
||||
events.push("first:start");
|
||||
throw new Error("sync boom");
|
||||
});
|
||||
|
||||
const second = withLock(async () => {
|
||||
events.push("second:start");
|
||||
events.push("second:end");
|
||||
return "ok";
|
||||
});
|
||||
|
||||
await expect(first).rejects.toThrow("sync boom");
|
||||
await expect(second).resolves.toBe("ok");
|
||||
expect(events).toEqual(["first:start", "second:start", "second:end"]);
|
||||
expect(events).toEqual(expectedEvents);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}.`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,33 +15,38 @@ describe("network-interfaces", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists trimmed non-internal external addresses only", () => {
|
||||
const snapshot = makeNetworkInterfacesSnapshot({
|
||||
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
|
||||
en0: [
|
||||
{ address: " 192.168.1.42 ", family: "IPv4" },
|
||||
{ address: "fd7a:115c:a1e0::1", family: "IPv6" },
|
||||
{ address: " ", family: "IPv6" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(listExternalInterfaceAddresses(snapshot)).toEqual([
|
||||
{ name: "en0", address: "192.168.1.42", family: "IPv4" },
|
||||
{ name: "en0", address: "fd7a:115c:a1e0::1", family: "IPv6" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers configured interface names before falling back", () => {
|
||||
const snapshot = makeNetworkInterfacesSnapshot({
|
||||
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
|
||||
en0: [{ address: "192.168.1.42", family: "IPv4" }],
|
||||
});
|
||||
|
||||
expect(
|
||||
pickMatchingExternalInterfaceAddress(snapshot, {
|
||||
family: "IPv4",
|
||||
preferredNames: ["en0", "eth0"],
|
||||
it.each([
|
||||
{
|
||||
name: "lists trimmed non-internal external addresses only",
|
||||
snapshot: makeNetworkInterfacesSnapshot({
|
||||
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
|
||||
en0: [
|
||||
{ address: " 192.168.1.42 ", family: "IPv4" },
|
||||
{ address: "fd7a:115c:a1e0::1", family: "IPv6" },
|
||||
{ address: " ", family: "IPv6" },
|
||||
],
|
||||
}),
|
||||
).toBe("192.168.1.42");
|
||||
run: (snapshot: ReturnType<typeof makeNetworkInterfacesSnapshot>) =>
|
||||
expect(listExternalInterfaceAddresses(snapshot)).toEqual([
|
||||
{ name: "en0", address: "192.168.1.42", family: "IPv4" },
|
||||
{ name: "en0", address: "fd7a:115c:a1e0::1", family: "IPv6" },
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: "prefers configured interface names before falling back",
|
||||
snapshot: makeNetworkInterfacesSnapshot({
|
||||
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
|
||||
en0: [{ address: "192.168.1.42", family: "IPv4" }],
|
||||
}),
|
||||
run: (snapshot: ReturnType<typeof makeNetworkInterfacesSnapshot>) =>
|
||||
expect(
|
||||
pickMatchingExternalInterfaceAddress(snapshot, {
|
||||
family: "IPv4",
|
||||
preferredNames: ["en0", "eth0"],
|
||||
}),
|
||||
).toBe("192.168.1.42"),
|
||||
},
|
||||
])("$name", ({ snapshot, run }) => {
|
||||
run(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -6,59 +6,66 @@ import {
|
||||
parseStrictPositiveInteger,
|
||||
} from "./parse-finite-number.js";
|
||||
|
||||
describe("parseFiniteNumber", () => {
|
||||
it.each([
|
||||
{ value: 42, expected: 42 },
|
||||
{ value: "3.14", expected: 3.14 },
|
||||
{ value: " 3.14ms", expected: 3.14 },
|
||||
{ value: "+7", expected: 7 },
|
||||
{ value: "1e3", expected: 1000 },
|
||||
])("parses %j", ({ value, expected }) => {
|
||||
expect(parseFiniteNumber(value)).toBe(expected);
|
||||
});
|
||||
function expectParserCases<T>(
|
||||
parse: (value: unknown) => T | undefined,
|
||||
cases: Array<{ value: unknown; expected: T | undefined }>,
|
||||
) {
|
||||
for (const { value, expected } of cases) {
|
||||
expect(parse(value)).toBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, "not-a-number", " ", "", null])(
|
||||
"returns undefined for %j",
|
||||
(value) => {
|
||||
expect(parseFiniteNumber(value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
describe("parseFiniteNumber", () => {
|
||||
it("parses finite values and rejects invalid inputs", () => {
|
||||
expectParserCases(parseFiniteNumber, [
|
||||
{ value: 42, expected: 42 },
|
||||
{ value: "3.14", expected: 3.14 },
|
||||
{ value: " 3.14ms", expected: 3.14 },
|
||||
{ value: "+7", expected: 7 },
|
||||
{ value: "1e3", expected: 1000 },
|
||||
{ value: Number.NaN, expected: undefined },
|
||||
{ value: Number.POSITIVE_INFINITY, expected: undefined },
|
||||
{ value: "not-a-number", expected: undefined },
|
||||
{ value: " ", expected: undefined },
|
||||
{ value: "", expected: undefined },
|
||||
{ value: null, expected: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStrictInteger", () => {
|
||||
it.each([
|
||||
{ value: "42", expected: 42 },
|
||||
{ value: " -7 ", expected: -7 },
|
||||
{ value: 12, expected: 12 },
|
||||
{ value: "+9", expected: 9 },
|
||||
])("parses %j", ({ value, expected }) => {
|
||||
expect(parseStrictInteger(value)).toBe(expected);
|
||||
it("parses strict integers and rejects non-integers", () => {
|
||||
expectParserCases(parseStrictInteger, [
|
||||
{ value: "42", expected: 42 },
|
||||
{ value: " -7 ", expected: -7 },
|
||||
{ value: 12, expected: 12 },
|
||||
{ value: "+9", expected: 9 },
|
||||
{ value: "42ms", expected: undefined },
|
||||
{ value: "0abc", expected: undefined },
|
||||
{ value: "1.5", expected: undefined },
|
||||
{ value: "1e3", expected: undefined },
|
||||
{ value: " ", expected: undefined },
|
||||
{ value: Number.MAX_SAFE_INTEGER + 1, expected: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["42ms", "0abc", "1.5", "1e3", " ", Number.MAX_SAFE_INTEGER + 1])(
|
||||
"rejects %j",
|
||||
(value) => {
|
||||
expect(parseStrictInteger(value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseStrictPositiveInteger", () => {
|
||||
it.each([
|
||||
{ value: "9", expected: 9 },
|
||||
{ value: "0", expected: undefined },
|
||||
{ value: "-1", expected: undefined },
|
||||
])("parses %j", ({ value, expected }) => {
|
||||
expect(parseStrictPositiveInteger(value)).toBe(expected);
|
||||
it("enforces positive integers", () => {
|
||||
expectParserCases(parseStrictPositiveInteger, [
|
||||
{ value: "9", expected: 9 },
|
||||
{ value: "0", expected: undefined },
|
||||
{ value: "-1", expected: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStrictNonNegativeInteger", () => {
|
||||
it.each([
|
||||
{ value: "0", expected: 0 },
|
||||
{ value: "9", expected: 9 },
|
||||
{ value: "-1", expected: undefined },
|
||||
])("parses %j", ({ value, expected }) => {
|
||||
expect(parseStrictNonNegativeInteger(value)).toBe(expected);
|
||||
it("allows zero and positive integers only", () => {
|
||||
expectParserCases(parseStrictNonNegativeInteger, [
|
||||
{ value: "0", expected: 0 },
|
||||
{ value: "9", expected: 9 },
|
||||
{ value: "-1", expected: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,72 +4,60 @@ import { describe, expect, it } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { assertNoPathAliasEscape } from "./path-alias-guards.js";
|
||||
|
||||
async function withAliasRoot(cb: (root: string) => Promise<void>): Promise<void> {
|
||||
await withTempDir(
|
||||
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
|
||||
cb,
|
||||
);
|
||||
}
|
||||
|
||||
describe("assertNoPathAliasEscape", () => {
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects broken final symlink targets outside root",
|
||||
async () => {
|
||||
await withTempDir(
|
||||
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
|
||||
async (root) => {
|
||||
const outside = path.join(path.dirname(root), "outside");
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
const linkPath = path.join(root, "jump");
|
||||
await fs.symlink(path.join(outside, "owned.txt"), linkPath);
|
||||
|
||||
await expect(
|
||||
assertNoPathAliasEscape({
|
||||
absolutePath: linkPath,
|
||||
rootPath: root,
|
||||
boundaryLabel: "sandbox root",
|
||||
}),
|
||||
).rejects.toThrow(/Symlink escapes sandbox root/);
|
||||
},
|
||||
);
|
||||
it.runIf(process.platform !== "win32").each([
|
||||
{
|
||||
name: "rejects broken final symlink targets outside root",
|
||||
setup: async (root: string) => {
|
||||
const outside = path.join(path.dirname(root), "outside");
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
const linkPath = path.join(root, "jump");
|
||||
await fs.symlink(path.join(outside, "owned.txt"), linkPath);
|
||||
return linkPath;
|
||||
},
|
||||
rejects: true,
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"allows broken final symlink targets that remain inside root",
|
||||
async () => {
|
||||
await withTempDir(
|
||||
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
|
||||
async (root) => {
|
||||
const linkPath = path.join(root, "jump");
|
||||
await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath);
|
||||
|
||||
await expect(
|
||||
assertNoPathAliasEscape({
|
||||
absolutePath: linkPath,
|
||||
rootPath: root,
|
||||
boundaryLabel: "sandbox root",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
{
|
||||
name: "allows broken final symlink targets that remain inside root",
|
||||
setup: async (root: string) => {
|
||||
const linkPath = path.join(root, "jump");
|
||||
await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath);
|
||||
return linkPath;
|
||||
},
|
||||
rejects: false,
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects broken targets that traverse via an in-root symlink alias",
|
||||
async () => {
|
||||
await withTempDir(
|
||||
{ prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" },
|
||||
async (root) => {
|
||||
const outside = path.join(path.dirname(root), "outside");
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
await fs.symlink(outside, path.join(root, "hop"));
|
||||
const linkPath = path.join(root, "jump");
|
||||
await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath);
|
||||
|
||||
await expect(
|
||||
assertNoPathAliasEscape({
|
||||
absolutePath: linkPath,
|
||||
rootPath: root,
|
||||
boundaryLabel: "sandbox root",
|
||||
}),
|
||||
).rejects.toThrow(/Symlink escapes sandbox root/);
|
||||
},
|
||||
);
|
||||
{
|
||||
name: "rejects broken targets that traverse via an in-root symlink alias",
|
||||
setup: async (root: string) => {
|
||||
const outside = path.join(path.dirname(root), "outside");
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
await fs.symlink(outside, path.join(root, "hop"));
|
||||
const linkPath = path.join(root, "jump");
|
||||
await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath);
|
||||
return linkPath;
|
||||
},
|
||||
rejects: true,
|
||||
},
|
||||
);
|
||||
])("$name", async ({ setup, rejects }) => {
|
||||
await withAliasRoot(async (root) => {
|
||||
const absolutePath = await setup(root);
|
||||
const promise = assertNoPathAliasEscape({
|
||||
absolutePath,
|
||||
rootPath: root,
|
||||
boundaryLabel: "sandbox root",
|
||||
});
|
||||
if (rejects) {
|
||||
await expect(promise).rejects.toThrow(/Symlink escapes sandbox root/);
|
||||
return;
|
||||
}
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./path-prepend.js";
|
||||
|
||||
const env = (value: Record<string, string>) => value;
|
||||
const pathLine = (...parts: string[]) => parts.join(path.delimiter);
|
||||
|
||||
describe("path prepend helpers", () => {
|
||||
it.each([
|
||||
@@ -36,9 +37,9 @@ describe("path prepend helpers", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
existingPath: `/usr/bin${path.delimiter}/opt/bin`,
|
||||
existingPath: pathLine("/usr/bin", "/opt/bin"),
|
||||
prepend: ["/custom/bin", "/usr/bin"],
|
||||
expected: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter),
|
||||
expected: pathLine("/custom/bin", "/usr/bin", "/opt/bin"),
|
||||
},
|
||||
{
|
||||
existingPath: undefined,
|
||||
@@ -53,7 +54,7 @@ describe("path prepend helpers", () => {
|
||||
{
|
||||
existingPath: ` /usr/bin ${path.delimiter} ${path.delimiter} /opt/bin `,
|
||||
prepend: ["/custom/bin"],
|
||||
expected: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter),
|
||||
expected: pathLine("/custom/bin", "/usr/bin", "/opt/bin"),
|
||||
},
|
||||
])("merges prepended paths for %j", ({ existingPath, prepend, expected }) => {
|
||||
expect(mergePathPrepend(existingPath, prepend)).toBe(expected);
|
||||
@@ -61,13 +62,13 @@ describe("path prepend helpers", () => {
|
||||
|
||||
it("applies prepends to the discovered PATH key and preserves existing casing", () => {
|
||||
const env = {
|
||||
Path: [`/usr/bin`, `/opt/bin`].join(path.delimiter),
|
||||
Path: pathLine("/usr/bin", "/opt/bin"),
|
||||
};
|
||||
|
||||
applyPathPrepend(env, ["/custom/bin", "/usr/bin"]);
|
||||
|
||||
expect(env).toEqual({
|
||||
Path: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter),
|
||||
Path: pathLine("/custom/bin", "/usr/bin", "/opt/bin"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,14 +98,19 @@ describe("path prepend helpers", () => {
|
||||
expect(env).toEqual(expected);
|
||||
});
|
||||
|
||||
it("creates PATH when prepends are provided and no path key exists", () => {
|
||||
const env = { HOME: "/tmp/home" };
|
||||
|
||||
applyPathPrepend(env, ["/custom/bin"]);
|
||||
|
||||
expect(env).toEqual({
|
||||
HOME: "/tmp/home",
|
||||
PATH: "/custom/bin",
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "creates PATH when prepends are provided and no path key exists",
|
||||
env: { HOME: "/tmp/home" },
|
||||
prepend: ["/custom/bin"],
|
||||
opts: undefined,
|
||||
expected: {
|
||||
HOME: "/tmp/home",
|
||||
PATH: "/custom/bin",
|
||||
},
|
||||
},
|
||||
])("$name", ({ env, prepend, opts, expected }) => {
|
||||
applyPathPrepend(env, prepend, opts);
|
||||
expect(env).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -2,6 +2,21 @@ import net from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryListenOnPort } from "./ports-probe.js";
|
||||
|
||||
async function withListeningServer(cb: (address: net.AddressInfo) => Promise<void>): Promise<void> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected tcp address");
|
||||
}
|
||||
|
||||
try {
|
||||
await cb(address);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
describe("tryListenOnPort", () => {
|
||||
it("can bind and release an ephemeral loopback port", async () => {
|
||||
await expect(tryListenOnPort({ port: 0, host: "127.0.0.1", exclusive: true })).resolves.toBe(
|
||||
@@ -10,21 +25,12 @@ describe("tryListenOnPort", () => {
|
||||
});
|
||||
|
||||
it("rejects when the port is already in use", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected tcp address");
|
||||
}
|
||||
|
||||
try {
|
||||
await withListeningServer(async (address) => {
|
||||
await expect(
|
||||
tryListenOnPort({ port: address.port, host: "127.0.0.1" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "EADDRINUSE",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,21 @@ import {
|
||||
withTimeout,
|
||||
} from "./provider-usage.shared.js";
|
||||
|
||||
async function withLegacyPiAuthFile(
|
||||
contents: string,
|
||||
run: (home: string) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-"));
|
||||
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
|
||||
await fs.writeFile(path.join(home, ".pi", "agent", "auth.json"), contents, "utf8");
|
||||
|
||||
try {
|
||||
await run(home);
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("provider-usage.shared", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -35,14 +50,23 @@ describe("provider-usage.shared", () => {
|
||||
expect(clampPercent(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns work result when it resolves before timeout", async () => {
|
||||
await expect(withTimeout(Promise.resolve("ok"), 100, "fallback")).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("propagates work errors before timeout", async () => {
|
||||
await expect(withTimeout(Promise.reject(new Error("boom")), 100, "fallback")).rejects.toThrow(
|
||||
"boom",
|
||||
);
|
||||
it.each([
|
||||
{
|
||||
name: "returns work result when it resolves before timeout",
|
||||
promise: () => Promise.resolve("ok"),
|
||||
expected: "ok",
|
||||
},
|
||||
{
|
||||
name: "propagates work errors before timeout",
|
||||
promise: () => Promise.reject(new Error("boom")),
|
||||
error: "boom",
|
||||
},
|
||||
])("$name", async ({ promise, expected, error }) => {
|
||||
if (error) {
|
||||
await expect(withTimeout(promise(), 100, "fallback")).rejects.toThrow(error);
|
||||
return;
|
||||
}
|
||||
await expect(withTimeout(promise(), 100, "fallback")).resolves.toBe(expected);
|
||||
});
|
||||
|
||||
it("returns fallback when timeout wins", async () => {
|
||||
@@ -61,33 +85,20 @@ describe("provider-usage.shared", () => {
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads legacy pi auth tokens for known provider aliases", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-"));
|
||||
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(home, ".pi", "agent", "auth.json"),
|
||||
`${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBe(
|
||||
"legacy-zai-key",
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for invalid legacy pi auth files", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-"));
|
||||
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
|
||||
await fs.writeFile(path.join(home, ".pi", "agent", "auth.json"), "{not-json", "utf8");
|
||||
|
||||
try {
|
||||
expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
it.each([
|
||||
{
|
||||
name: "reads legacy pi auth tokens for known provider aliases",
|
||||
contents: `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`,
|
||||
expected: "legacy-zai-key",
|
||||
},
|
||||
{
|
||||
name: "returns undefined for invalid legacy pi auth files",
|
||||
contents: "{not-json",
|
||||
expected: undefined,
|
||||
},
|
||||
])("$name", async ({ contents, expected }) => {
|
||||
await withLegacyPiAuthFile(contents, async (home) => {
|
||||
expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,24 @@ import {
|
||||
|
||||
let executables: Set<string>;
|
||||
|
||||
function addExecutables(...paths: string[]): void {
|
||||
for (const candidate of paths) {
|
||||
executables.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function expectDirsContainAll(dirs: readonly string[], expected: readonly string[]): void {
|
||||
for (const dir of expected) {
|
||||
expect(dirs).toContain(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function expectDirsExcludeAll(dirs: readonly string[], excluded: readonly string[]): void {
|
||||
for (const dir of excluded) {
|
||||
expect(dirs).not.toContain(dir);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
executables = new Set<string>();
|
||||
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
|
||||
@@ -30,21 +48,31 @@ describe("resolveSystemBin", () => {
|
||||
expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg");
|
||||
});
|
||||
|
||||
it("does NOT resolve a binary found in /usr/local/bin with strict trust", () => {
|
||||
executables.add("/usr/local/bin/openssl");
|
||||
expect(resolveSystemBin("openssl")).toBeNull();
|
||||
expect(resolveSystemBin("openssl", { trust: "strict" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT resolve a binary found in /opt/homebrew/bin with strict trust", () => {
|
||||
executables.add("/opt/homebrew/bin/ffmpeg");
|
||||
expect(resolveSystemBin("ffmpeg")).toBeNull();
|
||||
expect(resolveSystemBin("ffmpeg", { trust: "strict" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT resolve a binary from a user-writable directory like ~/.local/bin", () => {
|
||||
executables.add("/home/testuser/.local/bin/ffmpeg");
|
||||
expect(resolveSystemBin("ffmpeg")).toBeNull();
|
||||
it.each([
|
||||
{
|
||||
name: "does NOT resolve a binary found in /usr/local/bin with strict trust",
|
||||
executable: "/usr/local/bin/openssl",
|
||||
command: "openssl",
|
||||
checkStrict: true,
|
||||
},
|
||||
{
|
||||
name: "does NOT resolve a binary found in /opt/homebrew/bin with strict trust",
|
||||
executable: "/opt/homebrew/bin/ffmpeg",
|
||||
command: "ffmpeg",
|
||||
checkStrict: true,
|
||||
},
|
||||
{
|
||||
name: "does NOT resolve a binary from a user-writable directory like ~/.local/bin",
|
||||
executable: "/home/testuser/.local/bin/ffmpeg",
|
||||
command: "ffmpeg",
|
||||
checkStrict: false,
|
||||
},
|
||||
])("$name", ({ executable, command, checkStrict }) => {
|
||||
addExecutables(executable);
|
||||
expect(resolveSystemBin(command)).toBeNull();
|
||||
if (checkStrict) {
|
||||
expect(resolveSystemBin(command, { trust: "strict" })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers /usr/bin over /usr/local/bin (first match wins)", () => {
|
||||
@@ -79,15 +107,13 @@ describe("resolveSystemBin", () => {
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
it("resolves a binary in /opt/homebrew/bin with standard trust on macOS", () => {
|
||||
executables.add("/opt/homebrew/bin/ffmpeg");
|
||||
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/opt/homebrew/bin/ffmpeg");
|
||||
});
|
||||
|
||||
it("resolves a binary in /usr/local/bin with standard trust on macOS", () => {
|
||||
executables.add("/usr/local/bin/ffmpeg");
|
||||
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg");
|
||||
});
|
||||
it.each(["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"])(
|
||||
"resolves a binary in %s with standard trust on macOS",
|
||||
(executable) => {
|
||||
addExecutables(executable);
|
||||
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe(executable);
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers /usr/bin over /opt/homebrew/bin with standard trust", () => {
|
||||
executables.add("/usr/bin/ffmpeg");
|
||||
@@ -112,7 +138,7 @@ describe("resolveSystemBin", () => {
|
||||
|
||||
if (process.platform === "linux") {
|
||||
it("resolves a binary in /usr/local/bin with standard trust on Linux", () => {
|
||||
executables.add("/usr/local/bin/ffmpeg");
|
||||
addExecutables("/usr/local/bin/ffmpeg");
|
||||
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg");
|
||||
});
|
||||
|
||||
@@ -136,11 +162,8 @@ describe("trusted directory list", () => {
|
||||
if (process.platform !== "win32") {
|
||||
it("includes base Unix system directories only", () => {
|
||||
const dirs = _getTrustedDirs();
|
||||
expect(dirs).toContain("/usr/bin");
|
||||
expect(dirs).toContain("/bin");
|
||||
expect(dirs).toContain("/usr/sbin");
|
||||
expect(dirs).toContain("/sbin");
|
||||
expect(dirs).not.toContain("/usr/local/bin");
|
||||
expectDirsContainAll(dirs, ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]);
|
||||
expectDirsExcludeAll(dirs, ["/usr/local/bin"]);
|
||||
});
|
||||
|
||||
it("ignores env-controlled NIX_PROFILES entries, including direct store paths", () => {
|
||||
@@ -150,10 +173,12 @@ describe("trusted directory list", () => {
|
||||
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1 /tmp/evil /home/user/.nix-profile /nix/var/nix/profiles/default";
|
||||
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
|
||||
const dirs = _getTrustedDirs();
|
||||
expect(dirs).not.toContain("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin");
|
||||
expect(dirs).not.toContain("/tmp/evil/bin");
|
||||
expect(dirs).not.toContain("/home/user/.nix-profile/bin");
|
||||
expect(dirs).not.toContain("/nix/var/nix/profiles/default/bin");
|
||||
expectDirsExcludeAll(dirs, [
|
||||
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin",
|
||||
"/tmp/evil/bin",
|
||||
"/home/user/.nix-profile/bin",
|
||||
"/nix/var/nix/profiles/default/bin",
|
||||
]);
|
||||
} finally {
|
||||
if (saved === undefined) {
|
||||
delete process.env.NIX_PROFILES;
|
||||
@@ -167,14 +192,12 @@ describe("trusted directory list", () => {
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
it("does not include /opt/homebrew/bin in strict trust on macOS", () => {
|
||||
expect(_getTrustedDirs("strict")).not.toContain("/opt/homebrew/bin");
|
||||
expect(_getTrustedDirs("strict")).not.toContain("/usr/local/bin");
|
||||
expectDirsExcludeAll(_getTrustedDirs("strict"), ["/opt/homebrew/bin", "/usr/local/bin"]);
|
||||
});
|
||||
|
||||
it("includes /opt/homebrew/bin and /usr/local/bin in standard trust on macOS", () => {
|
||||
const dirs = _getTrustedDirs("standard");
|
||||
expect(dirs).toContain("/opt/homebrew/bin");
|
||||
expect(dirs).toContain("/usr/local/bin");
|
||||
expectDirsContainAll(dirs, ["/opt/homebrew/bin", "/usr/local/bin"]);
|
||||
});
|
||||
|
||||
it("places Homebrew dirs after system dirs in standard trust", () => {
|
||||
@@ -199,8 +222,7 @@ describe("trusted directory list", () => {
|
||||
if (process.platform === "linux") {
|
||||
it("includes Linux system-managed directories", () => {
|
||||
const dirs = _getTrustedDirs();
|
||||
expect(dirs).toContain("/run/current-system/sw/bin");
|
||||
expect(dirs).toContain("/snap/bin");
|
||||
expectDirsContainAll(dirs, ["/run/current-system/sw/bin", "/snap/bin"]);
|
||||
});
|
||||
|
||||
it("includes /usr/local/bin in standard trust on Linux", () => {
|
||||
@@ -285,13 +307,11 @@ describe("trusted directory list", () => {
|
||||
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
|
||||
const dirs = _getTrustedDirs();
|
||||
const normalizedDirs = dirs.map((dir) => dir.toLowerCase());
|
||||
expect(normalizedDirs).toContain(path.win32.join("C:\\Windows", "System32").toLowerCase());
|
||||
expect(normalizedDirs).toContain(
|
||||
expectDirsContainAll(normalizedDirs, [
|
||||
path.win32.join("C:\\Windows", "System32").toLowerCase(),
|
||||
path.win32.join("C:\\Program Files", "OpenSSL-Win64", "bin").toLowerCase(),
|
||||
);
|
||||
expect(normalizedDirs).toContain(
|
||||
path.win32.join("C:\\Program Files (x86)", "OpenSSL", "bin").toLowerCase(),
|
||||
);
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not include Unix paths on Windows", () => {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveRetryConfig, retryAsync } from "./retry.js";
|
||||
|
||||
type NumberRetryCase = {
|
||||
name: string;
|
||||
fn: ReturnType<typeof vi.fn>;
|
||||
attempts: number;
|
||||
initialDelayMs: number;
|
||||
expectedValue?: string;
|
||||
expectedError?: string;
|
||||
expectedCalls: number;
|
||||
};
|
||||
|
||||
async function runRetryAfterCase(params: {
|
||||
minDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
@@ -28,15 +38,15 @@ async function runRetryAfterCase(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function runRetryNumberCase<T>(
|
||||
fn: ReturnType<typeof vi.fn<() => Promise<T>>>,
|
||||
async function runRetryNumberCase(
|
||||
fn: ReturnType<typeof vi.fn>,
|
||||
attempts: number,
|
||||
initialDelayMs: number,
|
||||
): Promise<T> {
|
||||
): Promise<unknown> {
|
||||
vi.clearAllTimers();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const promise = retryAsync(fn, attempts, initialDelayMs);
|
||||
const promise = retryAsync(fn as () => Promise<unknown>, attempts, initialDelayMs);
|
||||
const settled = promise.then(
|
||||
(value) => ({ ok: true as const, value }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
@@ -64,25 +74,43 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("retryAsync", () => {
|
||||
it("returns on first success", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
const result = await retryAsync(fn, 3, 10);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries then succeeds", async () => {
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok");
|
||||
const result = await runRetryNumberCase(fn, 3, 1);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("propagates after exhausting retries", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(runRetryNumberCase(fn, 2, 1)).rejects.toThrow("boom");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each<NumberRetryCase>([
|
||||
{
|
||||
name: "returns on first success",
|
||||
fn: vi.fn().mockResolvedValue("ok"),
|
||||
attempts: 3,
|
||||
initialDelayMs: 10,
|
||||
expectedValue: "ok",
|
||||
expectedCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "retries then succeeds",
|
||||
fn: vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok"),
|
||||
attempts: 3,
|
||||
initialDelayMs: 1,
|
||||
expectedValue: "ok",
|
||||
expectedCalls: 2,
|
||||
},
|
||||
{
|
||||
name: "propagates after exhausting retries",
|
||||
fn: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
attempts: 2,
|
||||
initialDelayMs: 1,
|
||||
expectedError: "boom",
|
||||
expectedCalls: 2,
|
||||
},
|
||||
])(
|
||||
"$name",
|
||||
async ({ fn, attempts, initialDelayMs, expectedValue, expectedError, expectedCalls }) => {
|
||||
const result = runRetryNumberCase(fn, attempts, initialDelayMs);
|
||||
if (expectedError) {
|
||||
await expect(result).rejects.toThrow(expectedError);
|
||||
} else {
|
||||
await expect(result).resolves.toBe(expectedValue);
|
||||
}
|
||||
expect(fn).toHaveBeenCalledTimes(expectedCalls);
|
||||
},
|
||||
);
|
||||
|
||||
it("stops when shouldRetry returns false", async () => {
|
||||
const err = new Error("boom");
|
||||
@@ -133,19 +161,25 @@ describe("retryAsync", () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses retryAfterMs when provided", async () => {
|
||||
const delays = await runRetryAfterCase({ minDelayMs: 0, maxDelayMs: 1000, retryAfterMs: 500 });
|
||||
expect(delays[0]).toBe(500);
|
||||
});
|
||||
|
||||
it("clamps retryAfterMs to maxDelayMs", async () => {
|
||||
const delays = await runRetryAfterCase({ minDelayMs: 0, maxDelayMs: 100, retryAfterMs: 500 });
|
||||
expect(delays[0]).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps retryAfterMs to minDelayMs", async () => {
|
||||
const delays = await runRetryAfterCase({ minDelayMs: 250, maxDelayMs: 1000, retryAfterMs: 50 });
|
||||
expect(delays[0]).toBe(250);
|
||||
it.each([
|
||||
{
|
||||
name: "uses retryAfterMs when provided",
|
||||
params: { minDelayMs: 0, maxDelayMs: 1000, retryAfterMs: 500 },
|
||||
expectedDelay: 500,
|
||||
},
|
||||
{
|
||||
name: "clamps retryAfterMs to maxDelayMs",
|
||||
params: { minDelayMs: 0, maxDelayMs: 100, retryAfterMs: 500 },
|
||||
expectedDelay: 100,
|
||||
},
|
||||
{
|
||||
name: "clamps retryAfterMs to minDelayMs",
|
||||
params: { minDelayMs: 250, maxDelayMs: 1000, retryAfterMs: 50 },
|
||||
expectedDelay: 250,
|
||||
},
|
||||
])("$name", async ({ params, expectedDelay }) => {
|
||||
const delays = await runRetryAfterCase(params);
|
||||
expect(delays[0]).toBe(expectedDelay);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,17 @@ async function expectOpenFailure(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function expectOpenReason(
|
||||
opened: ReturnType<typeof openVerifiedFileSync>,
|
||||
expectedReason: "path" | "validation" | "io",
|
||||
): void {
|
||||
expect(opened.ok).toBe(false);
|
||||
if (opened.ok) {
|
||||
return;
|
||||
}
|
||||
expect(opened.reason).toBe(expectedReason);
|
||||
}
|
||||
|
||||
describe("openVerifiedFileSync", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -152,10 +163,7 @@ describe("openVerifiedFileSync", () => {
|
||||
filePath: "/input/file.txt",
|
||||
ioFs,
|
||||
});
|
||||
expect(opened.ok).toBe(false);
|
||||
if (!opened.ok) {
|
||||
expect(opened.reason).toBe("validation");
|
||||
}
|
||||
expectOpenReason(opened, "validation");
|
||||
expect(closed).toEqual([42]);
|
||||
});
|
||||
|
||||
@@ -178,9 +186,6 @@ describe("openVerifiedFileSync", () => {
|
||||
rejectPathSymlink: true,
|
||||
ioFs,
|
||||
});
|
||||
expect(opened.ok).toBe(false);
|
||||
if (!opened.ok) {
|
||||
expect(opened.reason).toBe("io");
|
||||
}
|
||||
expectOpenReason(opened, "io");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,38 +40,40 @@ describe("scp remote host", () => {
|
||||
});
|
||||
|
||||
describe("scp remote path", () => {
|
||||
it.each([
|
||||
{
|
||||
value: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
|
||||
expected: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
|
||||
},
|
||||
{
|
||||
value: " /Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg ",
|
||||
expected: "/Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg",
|
||||
},
|
||||
])("normalizes safe paths for %j", ({ value, expected }) => {
|
||||
expect(normalizeScpRemotePath(value)).toBe(expected);
|
||||
expect(isSafeScpRemotePath(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
undefined,
|
||||
"",
|
||||
" ",
|
||||
"relative/path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad$path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad`path`.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad;path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad|path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad&path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad<path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad>path.jpg",
|
||||
'/Users/demo/Library/Messages/Attachments/ab/cd/bad"path.jpg',
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad'path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad\\path.jpg",
|
||||
])("rejects unsafe path tokens: %j", (value) => {
|
||||
expect(normalizeScpRemotePath(value)).toBeUndefined();
|
||||
expect(isSafeScpRemotePath(value)).toBe(false);
|
||||
it.each(
|
||||
[
|
||||
{
|
||||
value: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
|
||||
normalized: "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg",
|
||||
safe: true,
|
||||
},
|
||||
{
|
||||
value: " /Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg ",
|
||||
normalized: "/Users/demo/Library/Messages/Attachments/ab/cd/IMG 1234 (1).jpg",
|
||||
safe: true,
|
||||
},
|
||||
null,
|
||||
undefined,
|
||||
"",
|
||||
" ",
|
||||
"relative/path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad$path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad`path`.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad;path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad|path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad&path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad<path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad>path.jpg",
|
||||
'/Users/demo/Library/Messages/Attachments/ab/cd/bad"path.jpg',
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad'path.jpg",
|
||||
"/Users/demo/Library/Messages/Attachments/ab/cd/bad\\path.jpg",
|
||||
].map((entry) =>
|
||||
typeof entry === "object" && entry !== null && "value" in entry
|
||||
? entry
|
||||
: { value: entry, normalized: undefined, safe: false },
|
||||
),
|
||||
)("classifies path token %j", ({ value, normalized, safe }) => {
|
||||
expect(normalizeScpRemotePath(value)).toBe(normalized);
|
||||
expect(isSafeScpRemotePath(value)).toBe(safe);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,24 @@ afterEach(async () => {
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
async function expectSecretFileError(params: {
|
||||
setup: (dir: string) => Promise<string>;
|
||||
expectedMessage: (file: string) => string;
|
||||
secretLabel?: string;
|
||||
options?: Parameters<typeof readSecretFileSync>[2];
|
||||
}): Promise<void> {
|
||||
const dir = await createTempDir();
|
||||
const file = await params.setup(dir);
|
||||
expect(() =>
|
||||
readSecretFileSync(file, params.secretLabel ?? "Gateway password", params.options),
|
||||
).toThrow(params.expectedMessage(file));
|
||||
}
|
||||
|
||||
async function createSecretPath(setup: (dir: string) => Promise<string>): Promise<string> {
|
||||
const dir = await createTempDir();
|
||||
return setup(dir);
|
||||
}
|
||||
|
||||
describe("readSecretFileSync", () => {
|
||||
it("rejects blank file paths", () => {
|
||||
expect(() => readSecretFileSync(" ", "Gateway password")).toThrow(
|
||||
@@ -32,104 +50,140 @@ describe("readSecretFileSync", () => {
|
||||
expect(tryReadSecretFileSync(file, "Gateway password")).toBe("top-secret");
|
||||
});
|
||||
|
||||
it("surfaces resolvedPath and error details for missing files", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "missing-secret.txt");
|
||||
it.each([
|
||||
{
|
||||
name: "surfaces resolvedPath and error details for missing files",
|
||||
assert: (file: string) => {
|
||||
expect(loadSecretFileSync(file, "Gateway password")).toMatchObject({
|
||||
ok: false,
|
||||
resolvedPath: file,
|
||||
message: expect.stringContaining(`Failed to inspect Gateway password file at ${file}:`),
|
||||
error: expect.any(Error),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preserves the underlying cause when throwing for missing files",
|
||||
assert: (file: string) => {
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
readSecretFileSync(file, "Gateway password");
|
||||
} catch (error) {
|
||||
thrown = error as Error;
|
||||
}
|
||||
|
||||
const result = loadSecretFileSync(file, "Gateway password");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
resolvedPath: file,
|
||||
message: expect.stringContaining(`Failed to inspect Gateway password file at ${file}:`),
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect(thrown?.message).toContain(`Failed to inspect Gateway password file at ${file}:`);
|
||||
expect((thrown as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
|
||||
},
|
||||
},
|
||||
])("$name", async ({ assert }) => {
|
||||
const file = await createSecretPath(async (dir) => path.join(dir, "missing-secret.txt"));
|
||||
assert(file);
|
||||
});
|
||||
|
||||
it("preserves the underlying cause when throwing for missing files", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "missing-secret.txt");
|
||||
it.each([
|
||||
{
|
||||
name: "rejects files larger than the secret-file limit",
|
||||
setup: async (dir: string) => {
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
|
||||
return file;
|
||||
},
|
||||
expectedMessage: (file: string) =>
|
||||
`Gateway password file at ${file} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`,
|
||||
},
|
||||
{
|
||||
name: "rejects non-regular files",
|
||||
setup: async (dir: string) => {
|
||||
const nestedDir = path.join(dir, "secret-dir");
|
||||
await mkdir(nestedDir);
|
||||
return nestedDir;
|
||||
},
|
||||
expectedMessage: (file: string) => `Gateway password file at ${file} must be a regular file.`,
|
||||
},
|
||||
{
|
||||
name: "rejects symlinks when configured",
|
||||
setup: async (dir: string) => {
|
||||
const target = path.join(dir, "target.txt");
|
||||
const link = path.join(dir, "secret-link.txt");
|
||||
await writeFile(target, "top-secret\n", "utf8");
|
||||
await symlink(target, link);
|
||||
return link;
|
||||
},
|
||||
options: { rejectSymlink: true },
|
||||
expectedMessage: (file: string) => `Gateway password file at ${file} must not be a symlink.`,
|
||||
},
|
||||
{
|
||||
name: "rejects empty secret files after trimming",
|
||||
setup: async (dir: string) => {
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, " \n\t ", "utf8");
|
||||
return file;
|
||||
},
|
||||
expectedMessage: (file: string) => `Gateway password file at ${file} is empty.`,
|
||||
},
|
||||
])("$name", async ({ setup, expectedMessage, options }) => {
|
||||
await expectSecretFileError({ setup, expectedMessage, options });
|
||||
});
|
||||
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
readSecretFileSync(file, "Gateway password");
|
||||
} catch (error) {
|
||||
thrown = error as Error;
|
||||
it.each([
|
||||
{
|
||||
name: "exposes resolvedPath on non-throwing read failures",
|
||||
pathValue: async () =>
|
||||
createSecretPath(async (dir) => {
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, " \n\t ", "utf8");
|
||||
return file;
|
||||
}),
|
||||
label: "Gateway password",
|
||||
options: undefined,
|
||||
helper: "load" as const,
|
||||
expected: (file: string | undefined) => ({
|
||||
ok: false,
|
||||
resolvedPath: file,
|
||||
message: `Gateway password file at ${file} is empty.`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "returns undefined from the non-throwing helper for rejected files",
|
||||
pathValue: async () =>
|
||||
createSecretPath(async (dir) => {
|
||||
const target = path.join(dir, "target.txt");
|
||||
const link = path.join(dir, "secret-link.txt");
|
||||
await writeFile(target, "top-secret\n", "utf8");
|
||||
await symlink(target, link);
|
||||
return link;
|
||||
}),
|
||||
label: "Telegram bot token",
|
||||
options: { rejectSymlink: true },
|
||||
helper: "try" as const,
|
||||
expected: () => undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined from the non-throwing helper for blank file paths",
|
||||
pathValue: async () => " ",
|
||||
label: "Telegram bot token",
|
||||
options: undefined,
|
||||
helper: "try" as const,
|
||||
expected: () => undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined from the non-throwing helper for missing path values",
|
||||
pathValue: async () => undefined,
|
||||
label: "Telegram bot token",
|
||||
options: undefined,
|
||||
helper: "try" as const,
|
||||
expected: () => undefined,
|
||||
},
|
||||
])("$name", async ({ pathValue, label, options, helper, expected }) => {
|
||||
const file = await pathValue();
|
||||
if (helper === "load") {
|
||||
expect(loadSecretFileSync(file as string, label, options)).toMatchObject(
|
||||
(expected as (file: string | undefined) => Record<string, unknown>)(file),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect(thrown?.message).toContain(`Failed to inspect Gateway password file at ${file}:`);
|
||||
expect((thrown as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("rejects files larger than the secret-file limit", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
|
||||
|
||||
expect(() => readSecretFileSync(file, "Gateway password")).toThrow(
|
||||
`Gateway password file at ${file} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-regular files", async () => {
|
||||
const dir = await createTempDir();
|
||||
const nestedDir = path.join(dir, "secret-dir");
|
||||
await mkdir(nestedDir);
|
||||
|
||||
expect(() => readSecretFileSync(nestedDir, "Gateway password")).toThrow(
|
||||
`Gateway password file at ${nestedDir} must be a regular file.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects symlinks when configured", async () => {
|
||||
const dir = await createTempDir();
|
||||
const target = path.join(dir, "target.txt");
|
||||
const link = path.join(dir, "secret-link.txt");
|
||||
await writeFile(target, "top-secret\n", "utf8");
|
||||
await symlink(target, link);
|
||||
|
||||
expect(() => readSecretFileSync(link, "Gateway password", { rejectSymlink: true })).toThrow(
|
||||
`Gateway password file at ${link} must not be a symlink.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty secret files after trimming", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, " \n\t ", "utf8");
|
||||
|
||||
expect(() => readSecretFileSync(file, "Gateway password")).toThrow(
|
||||
`Gateway password file at ${file} is empty.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes resolvedPath on non-throwing read failures", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, " \n\t ", "utf8");
|
||||
|
||||
expect(loadSecretFileSync(file, "Gateway password")).toMatchObject({
|
||||
ok: false,
|
||||
resolvedPath: file,
|
||||
message: `Gateway password file at ${file} is empty.`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined from the non-throwing helper for rejected files", async () => {
|
||||
const dir = await createTempDir();
|
||||
const target = path.join(dir, "target.txt");
|
||||
const link = path.join(dir, "secret-link.txt");
|
||||
await writeFile(target, "top-secret\n", "utf8");
|
||||
await symlink(target, link);
|
||||
|
||||
expect(tryReadSecretFileSync(link, "Telegram bot token", { rejectSymlink: true })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined from the non-throwing helper for blank file paths", () => {
|
||||
expect(tryReadSecretFileSync(" ", "Telegram bot token")).toBeUndefined();
|
||||
expect(tryReadSecretFileSync(undefined, "Telegram bot token")).toBeUndefined();
|
||||
expect(tryReadSecretFileSync(file, label, options)).toBe((expected as () => undefined)());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_-]*$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 --", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,19 @@ function makeZoneOpts(overrides: Partial<WideAreaGatewayZoneOpts> = {}): WideAre
|
||||
return { ...baseZoneOpts, ...overrides };
|
||||
}
|
||||
|
||||
function renderZoneText(overrides: Partial<WideAreaGatewayZoneOpts> = {}): string {
|
||||
return renderWideAreaGatewayZoneText({
|
||||
...makeZoneOpts(overrides),
|
||||
serial: 2025121701,
|
||||
});
|
||||
}
|
||||
|
||||
function expectZoneRecords(text: string, records: string[]): void {
|
||||
for (const record of records) {
|
||||
expect(text).toContain(record);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
@@ -79,68 +92,56 @@ describe("wide-area DNS discovery domain helpers", () => {
|
||||
|
||||
describe("wide-area DNS-SD zone rendering", () => {
|
||||
it("renders a zone with gateway PTR/SRV/TXT records", () => {
|
||||
const txt = renderWideAreaGatewayZoneText({
|
||||
domain: "openclaw.internal.",
|
||||
serial: 2025121701,
|
||||
gatewayPort: 18789,
|
||||
displayName: "Mac Studio (OpenClaw)",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
const txt = renderZoneText({
|
||||
tailnetIPv6: "fd7a:115c:a1e0::8801:e04c",
|
||||
hostLabel: "studio-london",
|
||||
instanceLabel: "studio-london",
|
||||
sshPort: 22,
|
||||
cliPath: "/opt/homebrew/bin/openclaw",
|
||||
});
|
||||
|
||||
expect(txt).toContain(`$ORIGIN openclaw.internal.`);
|
||||
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
|
||||
expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`);
|
||||
expect(txt).toContain(`_openclaw-gw._tcp IN PTR studio-london._openclaw-gw._tcp`);
|
||||
expect(txt).toContain(`studio-london._openclaw-gw._tcp IN SRV 0 0 18789 studio-london`);
|
||||
expect(txt).toContain(`displayName=Mac Studio (OpenClaw)`);
|
||||
expect(txt).toContain(`gatewayPort=18789`);
|
||||
expect(txt).toContain(`sshPort=22`);
|
||||
expect(txt).toContain(`cliPath=/opt/homebrew/bin/openclaw`);
|
||||
expectZoneRecords(txt, [
|
||||
`$ORIGIN openclaw.internal.`,
|
||||
`studio-london IN A 100.123.224.76`,
|
||||
`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`,
|
||||
`_openclaw-gw._tcp IN PTR studio-london._openclaw-gw._tcp`,
|
||||
`studio-london._openclaw-gw._tcp IN SRV 0 0 18789 studio-london`,
|
||||
`displayName=Mac Studio (OpenClaw)`,
|
||||
`gatewayPort=18789`,
|
||||
`sshPort=22`,
|
||||
`cliPath=/opt/homebrew/bin/openclaw`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes tailnetDns when provided", () => {
|
||||
const txt = renderWideAreaGatewayZoneText({
|
||||
domain: "openclaw.internal.",
|
||||
serial: 2025121701,
|
||||
gatewayPort: 18789,
|
||||
displayName: "Mac Studio (OpenClaw)",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
|
||||
hostLabel: "studio-london",
|
||||
instanceLabel: "studio-london",
|
||||
});
|
||||
|
||||
expect(txt).toContain(`tailnetDns=peters-mac-studio-1.sheep-coho.ts.net`);
|
||||
});
|
||||
|
||||
it("includes gateway TLS TXT fields and trims display metadata", () => {
|
||||
const txt = renderWideAreaGatewayZoneText({
|
||||
domain: "openclaw.internal",
|
||||
serial: 2025121701,
|
||||
gatewayPort: 18789,
|
||||
displayName: " Mac Studio (OpenClaw) ",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
hostLabel: " Studio London ",
|
||||
instanceLabel: " Studio London ",
|
||||
gatewayTlsEnabled: true,
|
||||
gatewayTlsFingerprintSha256: "abc123",
|
||||
tailnetDns: " tailnet.ts.net ",
|
||||
cliPath: " /opt/homebrew/bin/openclaw ",
|
||||
});
|
||||
|
||||
expect(txt).toContain(`$ORIGIN openclaw.internal.`);
|
||||
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
|
||||
expect(txt).toContain(`studio-london._openclaw-gw._tcp IN TXT`);
|
||||
expect(txt).toContain(`displayName=Mac Studio (OpenClaw)`);
|
||||
expect(txt).toContain(`gatewayTls=1`);
|
||||
expect(txt).toContain(`gatewayTlsSha256=abc123`);
|
||||
expect(txt).toContain(`tailnetDns=tailnet.ts.net`);
|
||||
expect(txt).toContain(`cliPath=/opt/homebrew/bin/openclaw`);
|
||||
it.each([
|
||||
{
|
||||
name: "includes tailnetDns when provided",
|
||||
overrides: { tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net" },
|
||||
records: [`tailnetDns=peters-mac-studio-1.sheep-coho.ts.net`],
|
||||
},
|
||||
{
|
||||
name: "includes gateway TLS TXT fields and trims display metadata",
|
||||
overrides: {
|
||||
domain: "openclaw.internal",
|
||||
displayName: " Mac Studio (OpenClaw) ",
|
||||
hostLabel: " Studio London ",
|
||||
instanceLabel: " Studio London ",
|
||||
gatewayTlsEnabled: true,
|
||||
gatewayTlsFingerprintSha256: "abc123",
|
||||
tailnetDns: " tailnet.ts.net ",
|
||||
cliPath: " /opt/homebrew/bin/openclaw ",
|
||||
},
|
||||
records: [
|
||||
`$ORIGIN openclaw.internal.`,
|
||||
`studio-london IN A 100.123.224.76`,
|
||||
`studio-london._openclaw-gw._tcp IN TXT`,
|
||||
`displayName=Mac Studio (OpenClaw)`,
|
||||
`gatewayTls=1`,
|
||||
`gatewayTlsSha256=abc123`,
|
||||
`tailnetDns=tailnet.ts.net`,
|
||||
`cliPath=/opt/homebrew/bin/openclaw`,
|
||||
],
|
||||
},
|
||||
])("$name", ({ overrides, records }) => {
|
||||
expectZoneRecords(renderZoneText(overrides), records);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user