diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 3f85153d9..fdc8c338a 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -27,6 +27,7 @@ vi.mock("../infra/update-check.js", async () => { ...actual, checkUpdateStatus: vi.fn(), fetchNpmTagVersion: vi.fn(), + resolveNpmChannelTag: vi.fn(), }; }); @@ -38,11 +39,6 @@ vi.mock("../commands/doctor.js", () => ({ vi.mock("./daemon-cli.js", () => ({ runDaemonRestart: vi.fn(), })); -// Mock plugin update helpers -vi.mock("../plugins/update.js", () => ({ - syncPluginsForUpdateChannel: vi.fn(), - updateNpmInstalledPlugins: vi.fn(), -})); // Mock the runtime vi.mock("../runtime.js", () => ({ @@ -78,15 +74,18 @@ describe("update-cli", () => { vi.clearAllMocks(); const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); const { readConfigFileSnapshot } = await import("../config/config.js"); - const { checkUpdateStatus, fetchNpmTagVersion } = await import("../infra/update-check.js"); - const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = - await import("../plugins/update.js"); + const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } = + await import("../infra/update-check.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(fetchNpmTagVersion).mockResolvedValue({ tag: "latest", version: "9999.0.0", }); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "9999.0.0", + }); vi.mocked(checkUpdateStatus).mockResolvedValue({ root: "/test/path", installKind: "git", @@ -112,16 +111,6 @@ describe("update-cli", () => { latestVersion: "1.2.3", }, }); - vi.mocked(syncPluginsForUpdateChannel).mockResolvedValue({ - config: baseSnapshot.config, - changed: false, - summary: { switchedToBundled: [], switchedToNpm: [], warnings: [], errors: [] }, - }); - vi.mocked(updateNpmInstalledPlugins).mockResolvedValue({ - config: baseSnapshot.config, - changed: false, - outcomes: [], - }); setTty(false); setStdoutTty(false); }); @@ -163,25 +152,6 @@ describe("update-cli", () => { expect(defaultRuntime.log).toHaveBeenCalled(); }); - it("updateCommand syncs plugins after a successful update", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = - await import("../plugins/update.js"); - const { updateCommand } = await import("./update-cli.js"); - - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }); - - await updateCommand({}); - - expect(syncPluginsForUpdateChannel).toHaveBeenCalled(); - expect(updateNpmInstalledPlugins).toHaveBeenCalled(); - }); - it("updateStatusCommand prints table output", async () => { const { defaultRuntime } = await import("../runtime.js"); const { updateStatusCommand } = await import("./update-cli.js"); @@ -274,6 +244,47 @@ describe("update-cli", () => { expect(call?.channel).toBe("beta"); }); + it("falls back to latest when beta tag is older than release", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); + try { + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "clawdbot", version: "2026.1.18-1" }), + "utf-8", + ); + + const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); + const { readConfigFileSnapshot } = await import("../config/config.js"); + const { resolveNpmChannelTag } = await import("../infra/update-check.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + config: { update: { channel: "beta" } }, + }); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "2026.1.20-1", + }); + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + steps: [], + durationMs: 100, + }); + + await updateCommand({}); + + const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; + expect(call?.channel).toBe("beta"); + expect(call?.tag).toBe("latest"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("honors --tag override", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); try { @@ -443,13 +454,13 @@ describe("update-cli", () => { ); const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); - const { fetchNpmTagVersion } = await import("../infra/update-check.js"); + const { resolveNpmChannelTag } = await import("../infra/update-check.js"); const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { defaultRuntime } = await import("../runtime.js"); const { updateCommand } = await import("./update-cli.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); - vi.mocked(fetchNpmTagVersion).mockResolvedValue({ + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "0.0.1", }); diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts new file mode 100644 index 000000000..628bb0452 --- /dev/null +++ b/src/infra/update-check.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveNpmChannelTag } from "./update-check.js"; + +describe("resolveNpmChannelTag", () => { + let versionByTag: Record; + + beforeEach(() => { + versionByTag = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const tag = decodeURIComponent(url.split("/").pop() ?? ""); + const version = versionByTag[tag] ?? null; + return { + ok: version != null, + status: version != null ? 200 : 404, + json: async () => ({ version }), + } as Response; + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("falls back to latest when beta is older", async () => { + versionByTag.beta = "2026.1.19-beta.1"; + versionByTag.latest = "2026.1.20-1"; + + const resolved = await resolveNpmChannelTag({ channel: "beta", timeoutMs: 1000 }); + + expect(resolved).toEqual({ tag: "latest", version: "2026.1.20-1" }); + }); + + it("keeps beta when beta is not older", async () => { + versionByTag.beta = "2026.1.20-beta.1"; + versionByTag.latest = "2026.1.20-1"; + + const resolved = await resolveNpmChannelTag({ channel: "beta", timeoutMs: 1000 }); + + expect(resolved).toEqual({ tag: "beta", version: "2026.1.20-beta.1" }); + }); +}); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index de2144f37..250101ad4 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -89,6 +89,42 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true); }); + it("uses stable tag when beta tag is older than release", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "clawdbot", version: "1.0.0", packageManager: "pnpm@8.0.0" }), + "utf-8", + ); + const stableTag = "v2026.1.20-1"; + const betaTag = "v2026.1.19-beta.2"; + const { runner, calls } = createRunner({ + [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, + [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, + [`git -C ${tempDir} status --porcelain`]: { stdout: "" }, + [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, + [`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { + stdout: `${stableTag}\n${betaTag}\n`, + }, + [`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" }, + "pnpm install": { stdout: "" }, + "pnpm build": { stdout: "" }, + "pnpm ui:build": { stdout: "" }, + "pnpm clawdbot doctor --non-interactive": { stdout: "" }, + }); + + const result = await runGatewayUpdate({ + cwd: tempDir, + runCommand: async (argv, _options) => runner(argv), + timeoutMs: 5000, + channel: "beta", + }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(`git -C ${tempDir} checkout --detach ${stableTag}`); + expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`); + }); + it("skips update when no git root", async () => { await fs.writeFile( path.join(tempDir, "package.json"), diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index f60b1fdec..ab80a0639 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -15,6 +15,7 @@ vi.mock("./update-check.js", async () => { ...actual, checkUpdateStatus: vi.fn(), fetchNpmTagVersion: vi.fn(), + resolveNpmChannelTag: vi.fn(), }; }); @@ -43,7 +44,7 @@ describe("update-startup", () => { it("logs update hint for npm installs when newer tag exists", async () => { const { resolveClawdbotPackageRoot } = await import("./clawdbot-root.js"); - const { checkUpdateStatus, fetchNpmTagVersion } = await import("./update-check.js"); + const { checkUpdateStatus, resolveNpmChannelTag } = await import("./update-check.js"); const { runGatewayUpdateCheck } = await import("./update-startup.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue("/opt/clawdbot"); @@ -52,7 +53,7 @@ describe("update-startup", () => { installKind: "package", packageManager: "npm", } satisfies UpdateCheckResult); - vi.mocked(fetchNpmTagVersion).mockResolvedValue({ + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2.0.0", }); @@ -75,6 +76,40 @@ describe("update-startup", () => { expect(parsed.lastNotifiedVersion).toBe("2.0.0"); }); + it("uses latest when beta tag is older than release", async () => { + const { resolveClawdbotPackageRoot } = await import("./clawdbot-root.js"); + const { checkUpdateStatus, resolveNpmChannelTag } = await import("./update-check.js"); + const { runGatewayUpdateCheck } = await import("./update-startup.js"); + + vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue("/opt/clawdbot"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/clawdbot", + installKind: "package", + packageManager: "npm", + } satisfies UpdateCheckResult); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "2.0.0", + }); + + const log = { info: vi.fn() }; + await runGatewayUpdateCheck({ + cfg: { update: { channel: "beta" } }, + log, + isNixMode: false, + allowInTests: true, + }); + + expect(log.info).toHaveBeenCalledWith( + expect.stringContaining("update available (latest): v2.0.0"), + ); + + const statePath = path.join(tempDir, "update-check.json"); + const raw = await fs.readFile(statePath, "utf-8"); + const parsed = JSON.parse(raw) as { lastNotifiedTag?: string }; + expect(parsed.lastNotifiedTag).toBe("latest"); + }); + it("skips update check when disabled in config", async () => { const { runGatewayUpdateCheck } = await import("./update-startup.js"); const log = { info: vi.fn() };