refactor: share startup recovery test helpers

This commit is contained in:
Vincent Koc
2026-06-01 16:31:36 +02:00
parent 12087ac9d4
commit 1b928592ef

View File

@@ -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<typeof testStartupLog>;
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}.`);
});
});