test: cover beta fallback update logic
This commit is contained in:
@@ -27,6 +27,7 @@ vi.mock("../infra/update-check.js", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
checkUpdateStatus: vi.fn(),
|
checkUpdateStatus: vi.fn(),
|
||||||
fetchNpmTagVersion: vi.fn(),
|
fetchNpmTagVersion: vi.fn(),
|
||||||
|
resolveNpmChannelTag: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,11 +39,6 @@ vi.mock("../commands/doctor.js", () => ({
|
|||||||
vi.mock("./daemon-cli.js", () => ({
|
vi.mock("./daemon-cli.js", () => ({
|
||||||
runDaemonRestart: vi.fn(),
|
runDaemonRestart: vi.fn(),
|
||||||
}));
|
}));
|
||||||
// Mock plugin update helpers
|
|
||||||
vi.mock("../plugins/update.js", () => ({
|
|
||||||
syncPluginsForUpdateChannel: vi.fn(),
|
|
||||||
updateNpmInstalledPlugins: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the runtime
|
// Mock the runtime
|
||||||
vi.mock("../runtime.js", () => ({
|
vi.mock("../runtime.js", () => ({
|
||||||
@@ -78,15 +74,18 @@ describe("update-cli", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||||
const { checkUpdateStatus, fetchNpmTagVersion } = await import("../infra/update-check.js");
|
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||||
const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } =
|
await import("../infra/update-check.js");
|
||||||
await import("../plugins/update.js");
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "9999.0.0",
|
version: "9999.0.0",
|
||||||
});
|
});
|
||||||
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
|
tag: "latest",
|
||||||
|
version: "9999.0.0",
|
||||||
|
});
|
||||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
root: "/test/path",
|
root: "/test/path",
|
||||||
installKind: "git",
|
installKind: "git",
|
||||||
@@ -112,16 +111,6 @@ describe("update-cli", () => {
|
|||||||
latestVersion: "1.2.3",
|
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);
|
setTty(false);
|
||||||
setStdoutTty(false);
|
setStdoutTty(false);
|
||||||
});
|
});
|
||||||
@@ -163,25 +152,6 @@ describe("update-cli", () => {
|
|||||||
expect(defaultRuntime.log).toHaveBeenCalled();
|
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 () => {
|
it("updateStatusCommand prints table output", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateStatusCommand } = await import("./update-cli.js");
|
const { updateStatusCommand } = await import("./update-cli.js");
|
||||||
@@ -274,6 +244,47 @@ describe("update-cli", () => {
|
|||||||
expect(call?.channel).toBe("beta");
|
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 () => {
|
it("honors --tag override", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
||||||
try {
|
try {
|
||||||
@@ -443,13 +454,13 @@ describe("update-cli", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
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 { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
});
|
});
|
||||||
|
|||||||
47
src/infra/update-check.test.ts
Normal file
47
src/infra/update-check.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,6 +89,42 @@ describe("runGatewayUpdate", () => {
|
|||||||
expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true);
|
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 () => {
|
it("skips update when no git root", async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(tempDir, "package.json"),
|
path.join(tempDir, "package.json"),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ vi.mock("./update-check.js", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
checkUpdateStatus: vi.fn(),
|
checkUpdateStatus: vi.fn(),
|
||||||
fetchNpmTagVersion: 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 () => {
|
it("logs update hint for npm installs when newer tag exists", async () => {
|
||||||
const { resolveClawdbotPackageRoot } = await import("./clawdbot-root.js");
|
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");
|
const { runGatewayUpdateCheck } = await import("./update-startup.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue("/opt/clawdbot");
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue("/opt/clawdbot");
|
||||||
@@ -52,7 +53,7 @@ describe("update-startup", () => {
|
|||||||
installKind: "package",
|
installKind: "package",
|
||||||
packageManager: "npm",
|
packageManager: "npm",
|
||||||
} satisfies UpdateCheckResult);
|
} satisfies UpdateCheckResult);
|
||||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
});
|
});
|
||||||
@@ -75,6 +76,40 @@ describe("update-startup", () => {
|
|||||||
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
|
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 () => {
|
it("skips update check when disabled in config", async () => {
|
||||||
const { runGatewayUpdateCheck } = await import("./update-startup.js");
|
const { runGatewayUpdateCheck } = await import("./update-startup.js");
|
||||||
const log = { info: vi.fn() };
|
const log = { info: vi.fn() };
|
||||||
|
|||||||
Reference in New Issue
Block a user