From cda9eacada06efe8dff6718eded8ff1548498c1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 19:32:59 +0000 Subject: [PATCH] test: add json file and canvas host helper coverage --- src/infra/canvas-host-url.test.ts | 64 +++++++++++++++++++++++++++++ src/infra/json-files.test.ts | 68 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/infra/canvas-host-url.test.ts create mode 100644 src/infra/json-files.test.ts diff --git a/src/infra/canvas-host-url.test.ts b/src/infra/canvas-host-url.test.ts new file mode 100644 index 00000000000..2ca7401a2bb --- /dev/null +++ b/src/infra/canvas-host-url.test.ts @@ -0,0 +1,64 @@ +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({ + 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({ + 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({ + canvasPort: 18789, + requestHost: "gateway.example.com:9443", + forwardedProto: "https", + }), + ).toBe("https://gateway.example.com:9443"); + expect( + resolveCanvasHostUrl({ + canvasPort: 18789, + requestHost: "gateway.example.com", + forwardedProto: ["https", "http"], + }), + ).toBe("https://gateway.example.com:443"); + expect( + resolveCanvasHostUrl({ + 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({ + canvasPort: 3000, + requestHost: "not a host", + localAddress: "2001:db8::1", + scheme: "https", + }), + ).toBe("https://[2001:db8::1]:3000"); + }); +}); diff --git a/src/infra/json-files.test.ts b/src/infra/json-files.test.ts new file mode 100644 index 00000000000..d2d0fa600f5 --- /dev/null +++ b/src/infra/json-files.test.ts @@ -0,0 +1,68 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createAsyncLock, readJsonFile, writeJsonAtomic, writeTextAtomic } from "./json-files.js"; + +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("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 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', + ); + }); + + 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("serializes async lock callers even across rejections", async () => { + const withLock = createAsyncLock(); + const events: string[] = []; + + const first = withLock(async () => { + events.push("first:start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + events.push("first:end"); + throw new Error("boom"); + }); + + const second = withLock(async () => { + events.push("second:start"); + events.push("second:end"); + return "ok"; + }); + + await expect(first).rejects.toThrow("boom"); + await expect(second).resolves.toBe("ok"); + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }); +});