import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" ;
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js" ;
import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js" ;
vi.mock("../config/config.js" , () => ({
applyConfigOverrides: vi.fn((config: OpenClawConfig) => config),
isNixMode: false ,
readConfigFileSnapshot: vi.fn(),
recoverConfigFromLastKnownGood: vi.fn(),
recoverConfigFromJsonRootSuffix: vi.fn(),
shouldAttemptLastKnownGoodRecovery: vi.fn((snapshot: ConfigFileSnapshot) => {
if (snapshot.valid) {
return false ;
}
return !(
snapshot.legacyIssues.length === 0 &&
snapshot.issues.length > 0 &&
snapshot.issues.every((issue) => issue.path.startsWith("plugins.entries." ))
);
}),
writeConfigFile: vi.fn(),
}));
vi.mock("./config-recovery-notice.js" , () => ({
enqueueConfigRecoveryNotice: vi.fn(),
}));
let loadGatewayStartupConfigSnapshot: typeof import ("./server-startup-config.js" ).loadGatewayStartupConfigSnapshot;
let configIo: typeof import ("../config/config.js" );
let recoveryNotice: typeof import ("./config-recovery-notice.js" );
const configPath = "/tmp/openclaw-startup-recovery.json" ;
const validConfig = {
gateway: {
mode: "local" ,
},
} as OpenClawConfig;
function buildSnapshot(params: {
valid: boolean ;
raw: string;
config?: OpenClawConfig;
}): ConfigFileSnapshot {
return buildTestConfigSnapshot({
path: configPath,
exists: true ,
raw: params.raw,
parsed: params.config ?? null ,
valid: params.valid,
config: params.config ?? ({} as OpenClawConfig),
issues: params.valid ? [] : [{ path: "gateway.mode" , message: "Expected 'local' or 'remote'" }],
legacyIssues: [],
});
}
describe("gateway startup config recovery" , () => {
beforeAll(async () => {
({ loadGatewayStartupConfigSnapshot } = await import ("./server-startup-config.js" ));
configIo = await import ("../config/config.js" );
recoveryNotice = await import ("./config-recovery-notice.js" );
});
beforeEach(() => {
vi.clearAllMocks();
});
it("restores last-known-good config before startup validation" , async () => {
const invalidSnapshot = buildSnapshot({ valid: false , raw: "{ invalid json" });
const recoveredSnapshot = buildSnapshot({
valid: true ,
raw: `${JSON.stringify(validConfig)}\n`,
config: validConfig,
});
vi.mocked(configIo.readConfigFileSnapshot)
.mockResolvedValueOnce(invalidSnapshot)
.mockResolvedValueOnce(recoveredSnapshot);
vi.mocked(configIo.recoverConfigFromLastKnownGood).mockResolvedValueOnce(true );
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: true ,
log,
}),
).resolves.toEqual({
snapshot: recoveredSnapshot,
wroteConfig: true ,
});
expect(configIo.recoverConfigFromLastKnownGood).toHaveBeenCalledWith({
snapshot: invalidSnapshot,
reason: "startup-invalid-config" ,
});
expect(log.warn).toHaveBeenCalledWith(
`gateway: invalid config was restored from last-known-good backup: ${configPath}`,
);
expect(recoveryNotice.enqueueConfigRecoveryNotice).toHaveBeenCalledWith({
cfg: recoveredSnapshot.config,
phase: "startup" ,
reason: "startup-invalid-config" ,
configPath,
});
});
it("keeps startup validation loud when last-known-good recovery is unavailable" , async () => {
const invalidSnapshot = buildSnapshot({ valid: false , raw: "{ invalid json" });
vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot);
vi.mocked(configIo.recoverConfigFromLastKnownGood).mockResolvedValueOnce(false );
vi.mocked(configIo.recoverConfigFromJsonRootSuffix).mockResolvedValueOnce(false );
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: true ,
log: { info: vi.fn(), warn: vi.fn() },
}),
).rejects.toThrow(
`Invalid config at ${configPath}.\ngateway.mode: Expected 'local' or 'remote' \nRun "openclaw doctor --fix" to repair, then retry.`,
);
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled();
});
it("does not restore last-known-good for plugin-local startup invalidity" , 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 },
},
},
},
valid: false ,
config: {
gateway: { mode: "local" },
plugins: {
entries: {
feishu: { enabled: true },
},
},
} as OpenClawConfig,
issues: [
{
path: "plugins.entries.feishu" ,
message:
"plugin feishu: plugin requires OpenClaw >=2026.4.23, but this host is 2026.4.22; skipping load" ,
},
],
legacyIssues: [],
});
vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot);
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: true ,
log,
}),
).rejects.toThrow(`Invalid config at ${configPath}.`);
expect(configIo.recoverConfigFromLastKnownGood).not.toHaveBeenCalled();
expect(configIo.recoverConfigFromJsonRootSuffix).toHaveBeenCalledWith(invalidSnapshot);
expect(log.warn).toHaveBeenCalledWith(
`gateway: last-known-good recovery skipped for plugin-local config invalidity: ${configPath}`,
);
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled();
});
it("strips a valid JSON suffix when last-known-good recovery is unavailable" , async () => {
const invalidSnapshot = buildSnapshot({
valid: false ,
raw: `Found and updated: False \n${JSON.stringify(validConfig)}\n`,
});
const repairedSnapshot = buildSnapshot({
valid: true ,
raw: `${JSON.stringify(validConfig)}\n`,
config: validConfig,
});
vi.mocked(configIo.readConfigFileSnapshot)
.mockResolvedValueOnce(invalidSnapshot)
.mockResolvedValueOnce(repairedSnapshot);
vi.mocked(configIo.recoverConfigFromLastKnownGood).mockResolvedValueOnce(false );
vi.mocked(configIo.recoverConfigFromJsonRootSuffix).mockResolvedValueOnce(true );
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: true ,
log,
}),
).resolves.toEqual({
snapshot: repairedSnapshot,
wroteConfig: true ,
});
expect(configIo.recoverConfigFromJsonRootSuffix).toHaveBeenCalledWith(invalidSnapshot);
expect(log.warn).toHaveBeenCalledWith(
`gateway: invalid config was repaired by stripping a non-JSON prefix: ${configPath}`,
);
});
});
Messung V0.5 in Prozent C=96 H=89 G=92
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland