From c84c76ee66c3ec99c87e3f5d46b9d5677d9aa184 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 19:49:31 +0000 Subject: [PATCH] test: add clipboard and package helper coverage --- src/infra/clipboard.test.ts | 52 ++++++++++++++++++++++++++++++ src/infra/package-tag.test.ts | 21 ++++++++++++ src/infra/stable-node-path.test.ts | 40 +++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/infra/clipboard.test.ts create mode 100644 src/infra/package-tag.test.ts create mode 100644 src/infra/stable-node-path.test.ts diff --git a/src/infra/clipboard.test.ts b/src/infra/clipboard.test.ts new file mode 100644 index 00000000000..c511d430c3b --- /dev/null +++ b/src/infra/clipboard.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +const { copyToClipboard } = await import("./clipboard.js"); + +describe("copyToClipboard", () => { + beforeEach(() => { + runCommandWithTimeoutMock.mockReset(); + }); + + it("returns true on the first successful clipboard command", async () => { + runCommandWithTimeoutMock.mockResolvedValueOnce({ code: 0, killed: false }); + + await expect(copyToClipboard("hello")).resolves.toBe(true); + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["pbcopy"], { + timeoutMs: 3000, + input: "hello", + }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + }); + + it("falls through failed attempts until a later command succeeds", async () => { + runCommandWithTimeoutMock + .mockRejectedValueOnce(new Error("missing pbcopy")) + .mockResolvedValueOnce({ code: 1, killed: false }) + .mockResolvedValueOnce({ code: 0, killed: false }); + + await expect(copyToClipboard("hello")).resolves.toBe(true); + expect(runCommandWithTimeoutMock.mock.calls.map((call) => call[0])).toEqual([ + ["pbcopy"], + ["xclip", "-selection", "clipboard"], + ["wl-copy"], + ]); + }); + + it("returns false when every clipboard backend fails or is killed", async () => { + runCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, killed: true }) + .mockRejectedValueOnce(new Error("missing xclip")) + .mockResolvedValueOnce({ code: 1, killed: false }) + .mockRejectedValueOnce(new Error("missing clip.exe")) + .mockResolvedValueOnce({ code: 2, killed: false }); + + await expect(copyToClipboard("hello")).resolves.toBe(false); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/infra/package-tag.test.ts b/src/infra/package-tag.test.ts new file mode 100644 index 00000000000..794acf63093 --- /dev/null +++ b/src/infra/package-tag.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { normalizePackageTagInput } from "./package-tag.js"; + +describe("normalizePackageTagInput", () => { + const packageNames = ["openclaw", "@openclaw/plugin"] as const; + + it("returns null for blank inputs", () => { + expect(normalizePackageTagInput(undefined, packageNames)).toBeNull(); + expect(normalizePackageTagInput(" ", packageNames)).toBeNull(); + }); + + it("strips known package-name prefixes before returning the tag", () => { + expect(normalizePackageTagInput("openclaw@beta", packageNames)).toBe("beta"); + expect(normalizePackageTagInput("@openclaw/plugin@2026.2.24", packageNames)).toBe("2026.2.24"); + }); + + it("returns trimmed raw values when no package prefix matches", () => { + expect(normalizePackageTagInput(" latest ", packageNames)).toBe("latest"); + expect(normalizePackageTagInput("@other/plugin@beta", packageNames)).toBe("@other/plugin@beta"); + }); +}); diff --git a/src/infra/stable-node-path.test.ts b/src/infra/stable-node-path.test.ts new file mode 100644 index 00000000000..75121ba91b2 --- /dev/null +++ b/src/infra/stable-node-path.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveStableNodePath } from "./stable-node-path.js"; + +describe("resolveStableNodePath", () => { + it("returns non-cellar paths unchanged", async () => { + await expect(resolveStableNodePath("/usr/local/bin/node")).resolves.toBe("/usr/local/bin/node"); + }); + + it("prefers the Homebrew opt symlink for default and versioned formulas", async () => { + const prefix = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stable-node-")); + const defaultNode = path.join(prefix, "Cellar", "node", "25.7.0", "bin", "node"); + const versionedNode = path.join(prefix, "Cellar", "node@22", "22.17.0", "bin", "node"); + const optDefault = path.join(prefix, "opt", "node", "bin", "node"); + const optVersioned = path.join(prefix, "opt", "node@22", "bin", "node"); + + await fs.mkdir(path.dirname(optDefault), { recursive: true }); + await fs.mkdir(path.dirname(optVersioned), { recursive: true }); + await fs.writeFile(optDefault, "", "utf8"); + await fs.writeFile(optVersioned, "", "utf8"); + + await expect(resolveStableNodePath(defaultNode)).resolves.toBe(optDefault); + await expect(resolveStableNodePath(versionedNode)).resolves.toBe(optVersioned); + }); + + it("falls back to the bin symlink for the default formula, otherwise original path", async () => { + const prefix = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stable-node-")); + const defaultNode = path.join(prefix, "Cellar", "node", "25.7.0", "bin", "node"); + const versionedNode = path.join(prefix, "Cellar", "node@22", "22.17.0", "bin", "node"); + const binNode = path.join(prefix, "bin", "node"); + + await fs.mkdir(path.dirname(binNode), { recursive: true }); + await fs.writeFile(binNode, "", "utf8"); + + await expect(resolveStableNodePath(defaultNode)).resolves.toBe(binNode); + await expect(resolveStableNodePath(versionedNode)).resolves.toBe(versionedNode); + }); +});