diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index 8c0b62c5667..fe7058dc5b7 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + formatPrereleaseResolutionError, + isExactSemverVersion, + isPrereleaseSemverVersion, isPrereleaseResolutionAllowed, parseRegistryNpmSpec, validateRegistryNpmSpec, @@ -22,6 +25,51 @@ describe("npm registry spec validation", () => { "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", + ); + }); +}); + +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("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); + }); }); describe("npm prerelease resolution policy", () => { @@ -66,4 +114,44 @@ describe("npm prerelease resolution policy", () => { }), ).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(); + expect( + formatPrereleaseResolutionError({ + spec: bareSpec!, + resolvedVersion: "1.2.3-beta.1", + }), + ).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"); + }); });