From 7caf874546e73952027cd3b78acd562974188334 Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:20:05 -0500 Subject: [PATCH] test(update): cover restart gating --- CHANGELOG.md | 1 + src/gateway/server-methods/update.test.ts | 73 ++++++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5307072c4e3..b125b42a0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) - CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) - CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. +- Gateway/Update: prevent restart crash loops after failed self-updates by restarting only on successful updates, stopping early on failed install/build steps, and running `openclaw doctor --fix` during updates to sanitize config. (#18131) Thanks @RamiNoodle733. - CLI/Update: run a standalone restart helper after updates, honoring service-name overrides and reporting restart initiation separately from confirmed restarts. (#18050) - CLI/Daemon: warn when a gateway restart sees a stale service token so users can reinstall with `openclaw gateway install --force`, and skip drift warnings for non-gateway service restarts. (#18018) - CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931. diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 68268e291cb..4468ba35ccf 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; +import type { UpdateRunResult } from "../../infra/update-runner.js"; // Capture the sentinel payload written during update.run let capturedPayload: RestartSentinelPayload | undefined; +const runGatewayUpdateMock = vi.fn<() => Promise>(); + +const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({ scheduled: true })); + vi.mock("../../config/config.js", () => ({ loadConfig: () => ({ update: {} }), })); @@ -43,7 +48,7 @@ vi.mock("../../infra/restart-sentinel.js", async (importOriginal) => { }); vi.mock("../../infra/restart.js", () => ({ - scheduleGatewaySigusr1Restart: () => ({ scheduled: true }), + scheduleGatewaySigusr1Restart: scheduleGatewaySigusr1RestartMock, })); vi.mock("../../infra/update-channels.js", () => ({ @@ -51,12 +56,7 @@ vi.mock("../../infra/update-channels.js", () => ({ })); vi.mock("../../infra/update-runner.js", () => ({ - runGatewayUpdate: async () => ({ - status: "ok", - mode: "npm", - steps: [], - durationMs: 100, - }), + runGatewayUpdate: runGatewayUpdateMock, })); vi.mock("../protocol/index.js", () => ({ @@ -75,6 +75,19 @@ vi.mock("./validation.js", () => ({ assertValidParams: () => true, })); +beforeEach(() => { + capturedPayload = undefined; + runGatewayUpdateMock.mockReset(); + runGatewayUpdateMock.mockResolvedValue({ + status: "ok", + mode: "npm", + steps: [], + durationMs: 100, + }); + scheduleGatewaySigusr1RestartMock.mockReset(); + scheduleGatewaySigusr1RestartMock.mockReturnValue({ scheduled: true }); +}); + describe("update.run sentinel deliveryContext", () => { it("includes deliveryContext in sentinel payload when sessionKey is provided", async () => { capturedPayload = undefined; @@ -132,3 +145,47 @@ describe("update.run sentinel deliveryContext", () => { expect(capturedPayload!.threadId).toBe("1234567890.123456"); }); }); + +describe("update.run restart scheduling", () => { + it("schedules restart when update succeeds", async () => { + const { updateHandlers } = await import("./update.js"); + const handler = updateHandlers["update.run"]; + let payload: { ok: boolean; restart: unknown } | undefined; + + await handler({ + params: {}, + respond: (_ok: boolean, response: { ok: boolean; restart: unknown }) => { + payload = response; + }, + } as never); + + expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); + expect(payload?.ok).toBe(true); + expect(payload?.restart).toEqual({ scheduled: true }); + }); + + it("skips restart when update fails", async () => { + runGatewayUpdateMock.mockResolvedValueOnce({ + status: "error", + mode: "git", + reason: "build-failed", + steps: [], + durationMs: 100, + }); + + const { updateHandlers } = await import("./update.js"); + const handler = updateHandlers["update.run"]; + let payload: { ok: boolean; restart: unknown } | undefined; + + await handler({ + params: {}, + respond: (_ok: boolean, response: { ok: boolean; restart: unknown }) => { + payload = response; + }, + } as never); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(payload?.ok).toBe(false); + expect(payload?.restart).toBeNull(); + }); +});