From 1b928592efb243bde08f4fdca4e0bb2cc699f5a3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 16:31:36 +0200 Subject: [PATCH] refactor: share startup recovery test helpers --- .../server-startup-config.recovery.test.ts | 351 +++++++----------- 1 file changed, 130 insertions(+), 221 deletions(-) diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index a59618b0354..8e5c83a7587 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -134,6 +134,77 @@ function buildDefaultSnapshot(): ConfigFileSnapshot { }); } +function buildRuntimeSnapshot( + sourceConfig: OpenClawConfig, + runtimeConfig: OpenClawConfig = sourceConfig, +): ConfigFileSnapshot { + return { + ...buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify(sourceConfig)}\n`, + parsed: sourceConfig, + valid: true, + config: runtimeConfig, + issues: [], + legacyIssues: [], + }), + sourceConfig, + resolved: sourceConfig, + runtimeConfig, + config: runtimeConfig, + } satisfies ConfigFileSnapshot; +} + +function mockStartupSnapshot(snapshot: ConfigFileSnapshot) { + vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ + snapshot, + pluginMetadataSnapshot, + }); +} + +function buildInvalidConfigSnapshot(params: { + rawConfig: unknown; + config?: OpenClawConfig; + issues: ConfigFileSnapshot["issues"]; + warnings?: ConfigFileSnapshot["warnings"]; + legacyIssues?: ConfigFileSnapshot["legacyIssues"]; +}) { + return buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify(params.rawConfig)}\n`, + parsed: params.rawConfig, + valid: false, + config: params.config ?? (params.rawConfig as OpenClawConfig), + issues: params.issues, + warnings: params.warnings, + legacyIssues: params.legacyIssues ?? [], + }); +} + +function testStartupLog() { + return { info: vi.fn(), warn: vi.fn() }; +} + +function loadTestStartup(params: { + minimalTestGateway?: boolean; + log?: ReturnType; + initialSnapshotRead?: Parameters< + typeof loadGatewayStartupConfigSnapshot + >[0]["initialSnapshotRead"]; +}) { + return loadGatewayStartupConfigSnapshot({ + minimalTestGateway: params.minimalTestGateway ?? true, + log: params.log ?? testStartupLog(), + initialSnapshotRead: params.initialSnapshotRead, + }); +} + +async function expectStartupRejects(message: string | RegExp, minimalTestGateway = true) { + await expect(loadTestStartup({ minimalTestGateway })).rejects.toThrow(message); +} + function installConfigIoMockDefaults() { const readSnapshot = vi.mocked(configIo.readConfigFileSnapshot); const readSnapshotWithPluginMetadata = vi.mocked( @@ -203,30 +274,12 @@ describe("gateway startup config validation", () => { }, }, } as OpenClawConfig; - const snapshot = { - ...buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify(sourceConfig)}\n`, - parsed: sourceConfig, - valid: true, - config: runtimeConfig, - issues: [], - legacyIssues: [], - }), - sourceConfig, - resolved: sourceConfig, - runtimeConfig, - config: runtimeConfig, - } satisfies ConfigFileSnapshot; - vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ - snapshot, - pluginMetadataSnapshot, - }); - const log = { info: vi.fn(), warn: vi.fn() }; + const snapshot = buildRuntimeSnapshot(sourceConfig, runtimeConfig); + mockStartupSnapshot(snapshot); + const log = testStartupLog(); await expect( - loadGatewayStartupConfigSnapshot({ + loadTestStartup({ minimalTestGateway: false, log, }), @@ -257,10 +310,10 @@ describe("gateway startup config validation", () => { issues: [], legacyIssues: [], }); - const log = { info: vi.fn(), warn: vi.fn() }; + const log = testStartupLog(); await expect( - loadGatewayStartupConfigSnapshot({ + loadTestStartup({ minimalTestGateway: false, log, initialSnapshotRead: { @@ -312,35 +365,17 @@ describe("gateway startup config validation", () => { telegram: { enabled: true }, }, } as unknown as OpenClawConfig; - const initialSnapshot = { - ...buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify(sourceConfig)}\n`, - parsed: sourceConfig, - valid: true, - config: sourceConfig, - issues: [], - legacyIssues: [], - }), - sourceConfig, - resolved: sourceConfig, - runtimeConfig: sourceConfig, - config: sourceConfig, - } satisfies ConfigFileSnapshot; - vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ - snapshot: initialSnapshot, - pluginMetadataSnapshot, - }); + const initialSnapshot = buildRuntimeSnapshot(sourceConfig); + mockStartupSnapshot(initialSnapshot); applyPluginAutoEnable.mockReturnValueOnce({ config: autoEnabledConfig, changes: ["Telegram configured, enabled automatically."], autoEnabledReasons: {}, }); - const log = { info: vi.fn(), warn: vi.fn() }; + const log = testStartupLog(); await expect( - loadGatewayStartupConfigSnapshot({ + loadTestStartup({ minimalTestGateway: false, log, }), @@ -394,36 +429,18 @@ describe("gateway startup config validation", () => { allow: ["telegram"], }, } as unknown as OpenClawConfig; - const snapshot = { - ...buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify(sourceConfig)}\n`, - parsed: sourceConfig, - valid: true, - config: sourceConfig, - issues: [], - legacyIssues: [], - }), - sourceConfig, - resolved: sourceConfig, - runtimeConfig: sourceConfig, - config: sourceConfig, - } satisfies ConfigFileSnapshot; - vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ - snapshot, - pluginMetadataSnapshot, - }); + const snapshot = buildRuntimeSnapshot(sourceConfig); + mockStartupSnapshot(snapshot); applyPluginAutoEnable.mockReturnValueOnce({ config: autoEnabledConfig, changes: ["Telegram configured, enabled automatically."], autoEnabledReasons: {}, }); configMocks.isNixMode.value = true; - const log = { info: vi.fn(), warn: vi.fn() }; + const log = testStartupLog(); await expect( - loadGatewayStartupConfigSnapshot({ + loadTestStartup({ minimalTestGateway: false, log, }), @@ -449,33 +466,19 @@ describe("gateway startup config validation", () => { const invalidSnapshot = buildSnapshot({ valid: false, raw: "{ invalid json" }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow( + await expectStartupRejects( `Invalid config at ${configPath}.\ngateway.mode: Expected 'local' or 'remote'\nRun "openclaw doctor --fix" to repair, then retry.\nIf startup is still blocked, inspect the adjacent .bak backup before restoring it manually.`, ); }); it("does not suggest doctor repair for plugin packaging compiled-output failures", async () => { - const invalidSnapshot = buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify({ - gateway: { mode: "local" }, - plugins: { slots: { memory: "source-only-pack" } }, - })}\n`, - parsed: { - gateway: { mode: "local" }, - plugins: { slots: { memory: "source-only-pack" } }, - }, - valid: false, - config: { - gateway: { mode: "local" }, - plugins: { slots: { memory: "source-only-pack" } }, - } as OpenClawConfig, + const rawConfig = { + gateway: { mode: "local" }, + plugins: { slots: { memory: "source-only-pack" } }, + }; + const invalidSnapshot = buildInvalidConfigSnapshot({ + rawConfig, + config: rawConfig as OpenClawConfig, issues: [ { path: "plugins.slots.memory", @@ -489,14 +492,10 @@ describe("gateway startup config validation", () => { "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", }, ], - legacyIssues: [], }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - const start = loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }); + const start = loadTestStartup({}); await expect(start).rejects.toThrow( `Invalid config at ${configPath}.\nplugins.slots.memory: plugin not found: source-only-pack\nThis is a plugin packaging issue, not a local config problem.\nUpdate or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.`, ); @@ -506,22 +505,13 @@ describe("gateway startup config validation", () => { }); it("keeps doctor repair guidance for mixed plugin packaging and core invalidity", async () => { - const invalidSnapshot = buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify({ - gateway: { mode: "invalid" }, - plugins: { slots: { memory: "source-only-pack" } }, - })}\n`, - parsed: { - gateway: { mode: "invalid" }, - plugins: { slots: { memory: "source-only-pack" } }, - }, - valid: false, - config: { - gateway: { mode: "invalid" }, - plugins: { slots: { memory: "source-only-pack" } }, - } as unknown as OpenClawConfig, + const rawConfig = { + gateway: { mode: "invalid" }, + plugins: { slots: { memory: "source-only-pack" } }, + }; + const invalidSnapshot = buildInvalidConfigSnapshot({ + rawConfig, + config: rawConfig as unknown as OpenClawConfig, issues: [ { path: "plugins.slots.memory", @@ -539,29 +529,17 @@ describe("gateway startup config validation", () => { "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js.", }, ], - legacyIssues: [], }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow('Run "openclaw doctor --fix" to repair, then retry.'); + await expectStartupRejects('Run "openclaw doctor --fix" to repair, then retry.'); }); it("rejects legacy config entries in Nix mode", async () => { - const legacySnapshot = buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify({ - heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" }, - })}\n`, - parsed: { + const legacySnapshot = buildInvalidConfigSnapshot({ + rawConfig: { heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" }, }, - valid: false, config: {} as OpenClawConfig, issues: [ { @@ -578,51 +556,26 @@ describe("gateway startup config validation", () => { }, ], }); - vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ - snapshot: legacySnapshot, - pluginMetadataSnapshot, - }); + mockStartupSnapshot(legacySnapshot); configMocks.isNixMode.value = true; - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow( + await expectStartupRejects( "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.", ); }); it("rejects plugin-local startup invalidity without degraded startup", async () => { - const invalidSnapshot = buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify({ - gateway: { mode: "local" }, - plugins: { - entries: { - feishu: { enabled: true }, - }, - }, - })}\n`, - parsed: { - gateway: { mode: "local" }, - plugins: { - entries: { - feishu: { enabled: true }, - }, + const rawConfig = { + gateway: { mode: "local" }, + plugins: { + entries: { + feishu: { enabled: true }, }, }, - valid: false, - config: { - gateway: { mode: "local" }, - plugins: { - entries: { - feishu: { enabled: true }, - }, - }, - } as OpenClawConfig, + }; + const invalidSnapshot = buildInvalidConfigSnapshot({ + rawConfig, + config: rawConfig as OpenClawConfig, issues: [ { path: "plugins.entries.feishu", @@ -630,46 +583,23 @@ describe("gateway startup config validation", () => { "plugin feishu: plugin requires OpenClaw >=2026.4.23, but this host is 2026.4.22; skipping load", }, ], - legacyIssues: [], }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow(`Invalid config at ${configPath}.`); + await expectStartupRejects(`Invalid config at ${configPath}.`); }); it("keeps mixed plugin and core startup invalidity fatal", async () => { - const invalidSnapshot = buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify({ - gateway: { mode: "invalid" }, - plugins: { - entries: { - feishu: { enabled: true }, - }, - }, - })}\n`, - parsed: { - gateway: { mode: "invalid" }, - plugins: { - entries: { - feishu: { enabled: true }, - }, + const rawConfig = { + gateway: { mode: "invalid" }, + plugins: { + entries: { + feishu: { enabled: true }, }, }, - valid: false, - config: { - gateway: { mode: "invalid" }, - plugins: { - entries: { - feishu: { enabled: true }, - }, - }, - } as unknown as OpenClawConfig, + }; + const invalidSnapshot = buildInvalidConfigSnapshot({ + rawConfig, + config: rawConfig as unknown as OpenClawConfig, issues: [ { path: "gateway.mode", @@ -680,16 +610,10 @@ describe("gateway startup config validation", () => { message: "invalid config: must be string", }, ], - legacyIssues: [], }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow(`Invalid config at ${configPath}.`); + await expectStartupRejects(`Invalid config at ${configPath}.`); }); it("rejects stale model provider api enum values during startup", async () => { @@ -721,12 +645,8 @@ describe("gateway startup config validation", () => { }, }, } as unknown as OpenClawConfig; - const invalidSnapshot = buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify(config)}\n`, - parsed: config, - valid: false, + const invalidSnapshot = buildInvalidConfigSnapshot({ + rawConfig: config, config, issues: [ { @@ -740,15 +660,9 @@ describe("gateway startup config validation", () => { 'Invalid option: expected one of "openai-completions"|"openai-responses"|"openai-chatgpt-responses"|"anthropic-messages"|"google-generative-ai"|"github-copilot"|"bedrock-converse-stream"|"ollama"|"azure-openai-responses"', }, ], - legacyIssues: [], }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: false, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow(`Invalid config at ${configPath}.`); + await expectStartupRejects(`Invalid config at ${configPath}.`, false); expect(configMutate.replaceConfigFile).not.toHaveBeenCalled(); }); @@ -760,11 +674,6 @@ describe("gateway startup config validation", () => { }); vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); - await expect( - loadGatewayStartupConfigSnapshot({ - minimalTestGateway: true, - log: { info: vi.fn(), warn: vi.fn() }, - }), - ).rejects.toThrow(`Invalid config at ${configPath}.`); + await expectStartupRejects(`Invalid config at ${configPath}.`); }); });