test: cover beta fallback update logic

This commit is contained in:
Peter Steinberger
2026-01-20 16:28:28 +00:00
parent 3d5ffee07f
commit cb5d76ed3d
4 changed files with 170 additions and 41 deletions

View File

@@ -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",
});

View File

@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveNpmChannelTag } from "./update-check.js";
describe("resolveNpmChannelTag", () => {
let versionByTag: Record<string, string | null>;
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" });
});
});

View File

@@ -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"),

View File

@@ -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() };