refactor(test): extract shared doctor migration test setup

This commit is contained in:
Peter Steinberger
2026-02-16 16:32:15 +00:00
parent 261f5ee492
commit ffeeb835aa
3 changed files with 161 additions and 222 deletions

View File

@@ -4,64 +4,54 @@ import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js"; import { withTempHome } from "../../test/helpers/temp-home.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
async function runDoctorConfigWithInput(params: {
config: Record<string, unknown>;
repair?: boolean;
}) {
return withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(params.config, null, 2),
"utf-8",
);
return loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: params.repair },
confirm: async () => false,
});
});
}
describe("doctor config flow", () => { describe("doctor config flow", () => {
it("preserves invalid config for doctor repairs", async () => { it("preserves invalid config for doctor repairs", async () => {
await withTempHome(async (home) => { const result = await runDoctorConfigWithInput({
const configDir = path.join(home, ".openclaw"); config: {
await fs.mkdir(configDir, { recursive: true }); gateway: { auth: { mode: "token", token: 123 } },
await fs.writeFile( agents: { list: [{ id: "pi" }] },
path.join(configDir, "openclaw.json"), },
JSON.stringify( });
{
gateway: { auth: { mode: "token", token: 123 } },
agents: { list: [{ id: "pi" }] },
},
null,
2,
),
"utf-8",
);
const result = await loadAndMaybeMigrateDoctorConfig({ expect((result.cfg as Record<string, unknown>).gateway).toEqual({
options: { nonInteractive: true }, auth: { mode: "token", token: 123 },
confirm: async () => false,
});
expect((result.cfg as Record<string, unknown>).gateway).toEqual({
auth: { mode: "token", token: 123 },
});
}); });
}); });
it("drops unknown keys on repair", async () => { it("drops unknown keys on repair", async () => {
await withTempHome(async (home) => { const result = await runDoctorConfigWithInput({
const configDir = path.join(home, ".openclaw"); repair: true,
await fs.mkdir(configDir, { recursive: true }); config: {
await fs.writeFile( bridge: { bind: "auto" },
path.join(configDir, "openclaw.json"), gateway: { auth: { mode: "token", token: "ok", extra: true } },
JSON.stringify( agents: { list: [{ id: "pi" }] },
{ },
bridge: { bind: "auto" }, });
gateway: { auth: { mode: "token", token: "ok", extra: true } },
agents: { list: [{ id: "pi" }] },
},
null,
2,
),
"utf-8",
);
const result = await loadAndMaybeMigrateDoctorConfig({ const cfg = result.cfg as Record<string, unknown>;
options: { nonInteractive: true, repair: true }, expect(cfg.bridge).toBeUndefined();
confirm: async () => false, expect((cfg.gateway as Record<string, unknown>)?.auth).toEqual({
}); mode: "token",
token: "ok",
const cfg = result.cfg as Record<string, unknown>;
expect(cfg.bridge).toBeUndefined();
expect((cfg.gateway as Record<string, unknown>)?.auth).toEqual({
mode: "token",
token: "ok",
});
}); });
}); });
@@ -86,60 +76,46 @@ describe("doctor config flow", () => {
}); });
vi.stubGlobal("fetch", fetchSpy); vi.stubGlobal("fetch", fetchSpy);
try { try {
await withTempHome(async (home) => { const result = await runDoctorConfigWithInput({
const configDir = path.join(home, ".openclaw"); repair: true,
await fs.mkdir(configDir, { recursive: true }); config: {
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
channels: {
telegram: {
botToken: "123:abc",
allowFrom: ["@testuser"],
groupAllowFrom: ["groupUser"],
groups: {
"-100123": {
allowFrom: ["tg:@topicUser"],
topics: { "99": { allowFrom: ["@accountUser"] } },
},
},
accounts: {
alerts: { botToken: "456:def", allowFrom: ["@accountUser"] },
},
},
},
},
null,
2,
),
"utf-8",
);
const result = await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const cfg = result.cfg as unknown as {
channels: { channels: {
telegram: { telegram: {
allowFrom: string[]; botToken: "123:abc",
groupAllowFrom: string[]; allowFrom: ["@testuser"],
groups: Record< groupAllowFrom: ["groupUser"],
string, groups: {
{ allowFrom: string[]; topics: Record<string, { allowFrom: string[] }> } "-100123": {
>; allowFrom: ["tg:@topicUser"],
accounts: Record<string, { allowFrom: string[] }>; topics: { "99": { allowFrom: ["@accountUser"] } },
}; },
},
accounts: {
alerts: { botToken: "456:def", allowFrom: ["@accountUser"] },
},
},
},
},
});
const cfg = result.cfg as unknown as {
channels: {
telegram: {
allowFrom: string[];
groupAllowFrom: string[];
groups: Record<
string,
{ allowFrom: string[]; topics: Record<string, { allowFrom: string[] }> }
>;
accounts: Record<string, { allowFrom: string[] }>;
}; };
}; };
expect(cfg.channels.telegram.allowFrom).toEqual(["111"]); };
expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]); expect(cfg.channels.telegram.allowFrom).toEqual(["111"]);
expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]); expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]);
expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]); expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]);
expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]); expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]);
}); expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]);
} finally { } finally {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
} }

View File

@@ -13,6 +13,15 @@ describe("normalizeLegacyConfigValues", () => {
fs.writeFileSync(path.join(dir, "creds.json"), JSON.stringify({ me: {} })); fs.writeFileSync(path.join(dir, "creds.json"), JSON.stringify({ me: {} }));
}; };
const expectNoWhatsAppConfigForLegacyAuth = (setup?: () => void) => {
setup?.();
const res = normalizeLegacyConfigValues({
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
});
expect(res.config.channels?.whatsapp).toBeUndefined();
expect(res.changes).toEqual([]);
};
beforeEach(() => { beforeEach(() => {
previousOauthDir = process.env.OPENCLAW_OAUTH_DIR; previousOauthDir = process.env.OPENCLAW_OAUTH_DIR;
tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-")); tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-"));
@@ -57,39 +66,24 @@ describe("normalizeLegacyConfigValues", () => {
}); });
it("does not add whatsapp config when only auth exists (issue #900)", () => { it("does not add whatsapp config when only auth exists (issue #900)", () => {
const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "default"); expectNoWhatsAppConfigForLegacyAuth(() => {
writeCreds(credsDir); const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "default");
writeCreds(credsDir);
const res = normalizeLegacyConfigValues({
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
}); });
expect(res.config.channels?.whatsapp).toBeUndefined();
expect(res.changes).toEqual([]);
}); });
it("does not add whatsapp config when only legacy auth exists (issue #900)", () => { it("does not add whatsapp config when only legacy auth exists (issue #900)", () => {
const credsPath = path.join(tempOauthDir ?? "", "creds.json"); expectNoWhatsAppConfigForLegacyAuth(() => {
fs.writeFileSync(credsPath, JSON.stringify({ me: {} })); const credsPath = path.join(tempOauthDir ?? "", "creds.json");
fs.writeFileSync(credsPath, JSON.stringify({ me: {} }));
const res = normalizeLegacyConfigValues({
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
}); });
expect(res.config.channels?.whatsapp).toBeUndefined();
expect(res.changes).toEqual([]);
}); });
it("does not add whatsapp config when only non-default auth exists (issue #900)", () => { it("does not add whatsapp config when only non-default auth exists (issue #900)", () => {
const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "work"); expectNoWhatsAppConfigForLegacyAuth(() => {
writeCreds(credsDir); const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "work");
writeCreds(credsDir);
const res = normalizeLegacyConfigValues({
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
}); });
expect(res.config.channels?.whatsapp).toBeUndefined();
expect(res.changes).toEqual([]);
}); });
it("copies legacy ack reaction when authDir override exists", () => { it("copies legacy ack reaction when authDir override exists", () => {

View File

@@ -68,6 +68,38 @@ async function runAndReadSessionsStore(params: {
return readSessionsStore(params.targetDir); return readSessionsStore(params.targetDir);
} }
type StateDirMigrationResult = Awaited<ReturnType<typeof autoMigrateLegacyStateDir>>;
const DIR_LINK_TYPE = process.platform === "win32" ? "junction" : "dir";
function getStateDirMigrationPaths(root: string) {
return {
targetDir: path.join(root, ".openclaw"),
legacyDir: path.join(root, ".clawdbot"),
};
}
function ensureLegacyAndTargetStateDirs(root: string) {
const paths = getStateDirMigrationPaths(root);
fs.mkdirSync(paths.targetDir, { recursive: true });
fs.mkdirSync(paths.legacyDir, { recursive: true });
return paths;
}
async function runStateDirMigration(root: string, env = {} as NodeJS.ProcessEnv) {
return autoMigrateLegacyStateDir({
env,
homedir: () => root,
});
}
function expectTargetAlreadyExistsWarning(result: StateDirMigrationResult, targetDir: string) {
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
}
describe("doctor legacy state migrations", () => { describe("doctor legacy state migrations", () => {
it("migrates legacy sessions into agents/<id>/sessions", async () => { it("migrates legacy sessions into agents/<id>/sessions", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
@@ -383,10 +415,7 @@ describe("doctor legacy state migrations", () => {
it("does nothing when no legacy state dir exists", async () => { it("does nothing when no legacy state dir exists", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false); expect(result.migrated).toBe(false);
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
@@ -395,13 +424,12 @@ describe("doctor legacy state migrations", () => {
it("skips state dir migration when env override is set", async () => { it("skips state dir migration when env override is set", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const legacyDir = path.join(root, ".openclaw"); const { legacyDir } = getStateDirMigrationPaths(root);
fs.mkdirSync(legacyDir, { recursive: true }); fs.mkdirSync(legacyDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root, {
env: { OPENCLAW_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, OPENCLAW_STATE_DIR: "/custom/state",
homedir: () => root, } as NodeJS.ProcessEnv);
});
expect(result.skipped).toBe(true); expect(result.skipped).toBe(true);
expect(result.migrated).toBe(false); expect(result.migrated).toBe(false);
@@ -409,20 +437,18 @@ describe("doctor legacy state migrations", () => {
it("does not warn when legacy state dir is an already-migrated symlink mirror", async () => { it("does not warn when legacy state dir is an already-migrated symlink mirror", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true });
fs.mkdirSync(path.join(targetDir, "agent"), { recursive: true }); fs.mkdirSync(path.join(targetDir, "agent"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir"; fs.symlinkSync(
fs.symlinkSync(path.join(targetDir, "sessions"), path.join(legacyDir, "sessions"), dirLinkType); path.join(targetDir, "sessions"),
fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), dirLinkType); path.join(legacyDir, "sessions"),
DIR_LINK_TYPE,
);
fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), DIR_LINK_TYPE);
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false); expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([]); expect(result.warnings).toEqual([]);
@@ -430,60 +456,34 @@ describe("doctor legacy state migrations", () => {
it("warns when legacy state dir is empty and target already exists", async () => { it("warns when legacy state dir is empty and target already exists", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(targetDir, { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv, expectTargetAlreadyExistsWarning(result, targetDir);
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
}); });
it("warns when legacy state dir contains non-symlink entries and target already exists", async () => { it("warns when legacy state dir contains non-symlink entries and target already exists", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(targetDir, { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, "sessions.json"), "{}", "utf-8"); fs.writeFileSync(path.join(legacyDir, "sessions.json"), "{}", "utf-8");
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv, expectTargetAlreadyExistsWarning(result, targetDir);
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
}); });
it("does not warn when legacy state dir contains nested symlink mirrors", async () => { it("does not warn when legacy state dir contains nested symlink mirrors", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(path.join(targetDir, "agents", "main"), { recursive: true }); fs.mkdirSync(path.join(targetDir, "agents", "main"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(path.join(legacyDir, "agents"), { recursive: true }); fs.mkdirSync(path.join(legacyDir, "agents"), { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync( fs.symlinkSync(
path.join(targetDir, "agents", "main"), path.join(targetDir, "agents", "main"),
path.join(legacyDir, "agents", "main"), path.join(legacyDir, "agents", "main"),
dirLinkType, DIR_LINK_TYPE,
); );
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false); expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([]); expect(result.warnings).toEqual([]);
@@ -491,72 +491,41 @@ describe("doctor legacy state migrations", () => {
it("warns when legacy state dir symlink points outside the target tree", async () => { it("warns when legacy state dir symlink points outside the target tree", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
const outsideDir = path.join(root, ".outside-state"); const outsideDir = path.join(root, ".outside-state");
fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir"; fs.symlinkSync(path.join(outsideDir), path.join(legacyDir, "sessions"), DIR_LINK_TYPE);
fs.symlinkSync(path.join(outsideDir), path.join(legacyDir, "sessions"), dirLinkType);
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv, expectTargetAlreadyExistsWarning(result, targetDir);
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
}); });
it("warns when legacy state dir contains a broken symlink target", async () => { it("warns when legacy state dir contains a broken symlink target", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
const targetSessionDir = path.join(targetDir, "sessions"); const targetSessionDir = path.join(targetDir, "sessions");
fs.symlinkSync(targetSessionDir, path.join(legacyDir, "sessions"), dirLinkType); fs.symlinkSync(targetSessionDir, path.join(legacyDir, "sessions"), DIR_LINK_TYPE);
fs.rmSync(targetSessionDir, { recursive: true, force: true }); fs.rmSync(targetSessionDir, { recursive: true, force: true });
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv, expectTargetAlreadyExistsWarning(result, targetDir);
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
}); });
it("warns when legacy symlink escapes target tree through second-hop symlink", async () => { it("warns when legacy symlink escapes target tree through second-hop symlink", async () => {
const root = await makeTempRoot(); const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw"); const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root);
const legacyDir = path.join(root, ".clawdbot");
const outsideDir = path.join(root, ".outside-state"); const outsideDir = path.join(root, ".outside-state");
fs.mkdirSync(targetDir, { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
const targetHop = path.join(targetDir, "hop"); const targetHop = path.join(targetDir, "hop");
fs.symlinkSync(outsideDir, targetHop, dirLinkType); fs.symlinkSync(outsideDir, targetHop, DIR_LINK_TYPE);
fs.symlinkSync(targetHop, path.join(legacyDir, "sessions"), dirLinkType); fs.symlinkSync(targetHop, path.join(legacyDir, "sessions"), DIR_LINK_TYPE);
const result = await autoMigrateLegacyStateDir({ const result = await runStateDirMigration(root);
env: {} as NodeJS.ProcessEnv, expectTargetAlreadyExistsWarning(result, targetDir);
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
}); });
}); });