diff --git a/src/infra/npm-pack-install.test.ts b/src/infra/npm-pack-install.test.ts index c0428ec03c5..94d732deef6 100644 --- a/src/infra/npm-pack-install.test.ts +++ b/src/infra/npm-pack-install.test.ts @@ -91,6 +91,24 @@ describe("installFromNpmSpecArchive", () => { expect(withTempDir).toHaveBeenCalledWith("openclaw-test-", expect.any(Function)); }); + it("rejects unsupported npm specs before packing", async () => { + const installFromArchive = vi.fn(async () => ({ ok: true as const })); + + const result = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-test-", + spec: "file:/tmp/openclaw.tgz", + timeoutMs: 1000, + installFromArchive, + }); + + expect(result).toEqual({ + ok: false, + error: "unsupported npm spec", + }); + expect(packNpmSpecToArchive).not.toHaveBeenCalled(); + expect(installFromArchive).not.toHaveBeenCalled(); + }); + it("returns resolution metadata and installer result on success", async () => { mockPackedSuccess({ name: "@openclaw/test", version: "1.0.0" }); const installFromArchive = vi.fn(async () => ({ ok: true as const, target: "done" })); @@ -176,6 +194,56 @@ describe("installFromNpmSpecArchive", () => { const okResult = expectWrappedOkResult(result, { ok: false, error: "install failed" }); expect(okResult.integrityDrift).toBeUndefined(); }); + + it("rejects prerelease resolutions unless explicitly requested", async () => { + vi.mocked(packNpmSpecToArchive).mockResolvedValue({ + ok: true, + archivePath: baseArchivePath, + metadata: { + resolvedSpec: "@openclaw/test@latest", + integrity: "sha512-same", + version: "1.1.0-beta.1", + }, + }); + const installFromArchive = vi.fn(async () => ({ ok: true as const })); + + const result = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-test-", + spec: "@openclaw/test@latest", + timeoutMs: 1000, + installFromArchive, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected prerelease rejection"); + } + expect(result.error).toContain("prerelease version 1.1.0-beta.1"); + expect(installFromArchive).not.toHaveBeenCalled(); + }); + + it("allows prerelease resolutions when explicitly requested by tag", async () => { + vi.mocked(packNpmSpecToArchive).mockResolvedValue({ + ok: true, + archivePath: baseArchivePath, + metadata: { + resolvedSpec: "@openclaw/test@beta", + integrity: "sha512-same", + version: "1.1.0-beta.1", + }, + }); + const installFromArchive = vi.fn(async () => ({ ok: true as const, pluginId: "beta-plugin" })); + + const result = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-test-", + spec: "@openclaw/test@beta", + timeoutMs: 1000, + installFromArchive, + }); + + const okResult = expectWrappedOkResult(result, { ok: true, pluginId: "beta-plugin" }); + expect(okResult.npmResolution.version).toBe("1.1.0-beta.1"); + }); }); describe("installFromNpmSpecArchiveWithInstaller", () => { diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 560902aee83..672fe91003c 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { compareSemverStrings, resolveNpmChannelTag } from "./update-check.js"; +import { + compareSemverStrings, + fetchNpmLatestVersion, + fetchNpmTagVersion, + formatGitInstallLabel, + resolveNpmChannelTag, +} from "./update-check.js"; describe("compareSemverStrings", () => { it("handles stable and prerelease precedence for both legacy and beta formats", () => { @@ -72,4 +78,81 @@ describe("resolveNpmChannelTag", () => { expect(resolved).toEqual({ tag: "latest", version: "1.0.1" }); }); + + it("keeps non-beta channels unchanged", async () => { + versionByTag.latest = "1.0.3"; + + await expect(resolveNpmChannelTag({ channel: "stable", timeoutMs: 1000 })).resolves.toEqual({ + tag: "latest", + version: "1.0.3", + }); + }); + + it("exposes tag fetch helpers for success and http failures", async () => { + versionByTag.latest = "1.0.4"; + + await expect(fetchNpmTagVersion({ tag: "latest", timeoutMs: 1000 })).resolves.toEqual({ + tag: "latest", + version: "1.0.4", + }); + await expect(fetchNpmLatestVersion({ timeoutMs: 1000 })).resolves.toEqual({ + latestVersion: "1.0.4", + error: undefined, + }); + await expect(fetchNpmTagVersion({ tag: "beta", timeoutMs: 1000 })).resolves.toEqual({ + tag: "beta", + version: null, + error: "HTTP 404", + }); + }); +}); + +describe("formatGitInstallLabel", () => { + it("formats branch, detached tag, and non-git installs", () => { + expect( + formatGitInstallLabel({ + root: "/repo", + installKind: "git", + packageManager: "pnpm", + git: { + root: "/repo", + sha: "1234567890abcdef", + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + }, + }), + ).toBe("main · @ 12345678"); + + expect( + formatGitInstallLabel({ + root: "/repo", + installKind: "git", + packageManager: "pnpm", + git: { + root: "/repo", + sha: "abcdef1234567890", + tag: "v1.2.3", + branch: "HEAD", + upstream: null, + dirty: false, + ahead: 0, + behind: 0, + fetchOk: null, + }, + }), + ).toBe("detached · tag v1.2.3 · @ abcdef12"); + + expect( + formatGitInstallLabel({ + root: null, + installKind: "package", + packageManager: "pnpm", + }), + ).toBeNull(); + }); });