diff --git a/src/commands/doctor-config-flow.e2e.test.ts b/src/commands/doctor-config-flow.e2e.test.ts index d544c5a42b6..aff8049ca0e 100644 --- a/src/commands/doctor-config-flow.e2e.test.ts +++ b/src/commands/doctor-config-flow.e2e.test.ts @@ -4,64 +4,54 @@ import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; +async function runDoctorConfigWithInput(params: { + config: Record; + 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", () => { it("preserves invalid config for doctor repairs", async () => { - await 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( - { - gateway: { auth: { mode: "token", token: 123 } }, - agents: { list: [{ id: "pi" }] }, - }, - null, - 2, - ), - "utf-8", - ); + const result = await runDoctorConfigWithInput({ + config: { + gateway: { auth: { mode: "token", token: 123 } }, + agents: { list: [{ id: "pi" }] }, + }, + }); - const result = await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true }, - confirm: async () => false, - }); - - expect((result.cfg as Record).gateway).toEqual({ - auth: { mode: "token", token: 123 }, - }); + expect((result.cfg as Record).gateway).toEqual({ + auth: { mode: "token", token: 123 }, }); }); it("drops unknown keys on repair", async () => { - await 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( - { - bridge: { bind: "auto" }, - gateway: { auth: { mode: "token", token: "ok", extra: true } }, - agents: { list: [{ id: "pi" }] }, - }, - null, - 2, - ), - "utf-8", - ); + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + bridge: { bind: "auto" }, + gateway: { auth: { mode: "token", token: "ok", extra: true } }, + agents: { list: [{ id: "pi" }] }, + }, + }); - const result = await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); - - const cfg = result.cfg as Record; - expect(cfg.bridge).toBeUndefined(); - expect((cfg.gateway as Record)?.auth).toEqual({ - mode: "token", - token: "ok", - }); + const cfg = result.cfg as Record; + expect(cfg.bridge).toBeUndefined(); + expect((cfg.gateway as Record)?.auth).toEqual({ + mode: "token", + token: "ok", }); }); @@ -86,60 +76,46 @@ describe("doctor config flow", () => { }); vi.stubGlobal("fetch", fetchSpy); try { - await 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( - { - 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 { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { channels: { telegram: { - allowFrom: string[]; - groupAllowFrom: string[]; - groups: Record< - string, - { allowFrom: string[]; topics: Record } - >; - accounts: Record; - }; + botToken: "123:abc", + allowFrom: ["@testuser"], + groupAllowFrom: ["groupUser"], + groups: { + "-100123": { + allowFrom: ["tg:@topicUser"], + 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 } + >; + accounts: Record; }; }; - expect(cfg.channels.telegram.allowFrom).toEqual(["111"]); - expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]); - expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]); - expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]); - expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]); - }); + }; + expect(cfg.channels.telegram.allowFrom).toEqual(["111"]); + expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]); + expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]); + expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]); + expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]); } finally { vi.unstubAllGlobals(); } diff --git a/src/commands/doctor-legacy-config.e2e.test.ts b/src/commands/doctor-legacy-config.e2e.test.ts index 6d7b04f4c49..43b097cecce 100644 --- a/src/commands/doctor-legacy-config.e2e.test.ts +++ b/src/commands/doctor-legacy-config.e2e.test.ts @@ -13,6 +13,15 @@ describe("normalizeLegacyConfigValues", () => { 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(() => { previousOauthDir = process.env.OPENCLAW_OAUTH_DIR; 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)", () => { - const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "default"); - writeCreds(credsDir); - - const res = normalizeLegacyConfigValues({ - messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + expectNoWhatsAppConfigForLegacyAuth(() => { + const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "default"); + writeCreds(credsDir); }); - - expect(res.config.channels?.whatsapp).toBeUndefined(); - expect(res.changes).toEqual([]); }); it("does not add whatsapp config when only legacy auth exists (issue #900)", () => { - const credsPath = path.join(tempOauthDir ?? "", "creds.json"); - fs.writeFileSync(credsPath, JSON.stringify({ me: {} })); - - const res = normalizeLegacyConfigValues({ - messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + expectNoWhatsAppConfigForLegacyAuth(() => { + const credsPath = path.join(tempOauthDir ?? "", "creds.json"); + fs.writeFileSync(credsPath, JSON.stringify({ me: {} })); }); - - expect(res.config.channels?.whatsapp).toBeUndefined(); - expect(res.changes).toEqual([]); }); it("does not add whatsapp config when only non-default auth exists (issue #900)", () => { - const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "work"); - writeCreds(credsDir); - - const res = normalizeLegacyConfigValues({ - messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + expectNoWhatsAppConfigForLegacyAuth(() => { + const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "work"); + writeCreds(credsDir); }); - - expect(res.config.channels?.whatsapp).toBeUndefined(); - expect(res.changes).toEqual([]); }); it("copies legacy ack reaction when authDir override exists", () => { diff --git a/src/commands/doctor-state-migrations.e2e.test.ts b/src/commands/doctor-state-migrations.e2e.test.ts index 36dd7fa6257..0deb332e9a4 100644 --- a/src/commands/doctor-state-migrations.e2e.test.ts +++ b/src/commands/doctor-state-migrations.e2e.test.ts @@ -68,6 +68,38 @@ async function runAndReadSessionsStore(params: { return readSessionsStore(params.targetDir); } +type StateDirMigrationResult = Awaited>; + +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", () => { it("migrates legacy sessions into agents//sessions", async () => { const root = await makeTempRoot(); @@ -383,10 +415,7 @@ describe("doctor legacy state migrations", () => { it("does nothing when no legacy state dir exists", async () => { const root = await makeTempRoot(); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); + const result = await runStateDirMigration(root); expect(result.migrated).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 () => { const root = await makeTempRoot(); - const legacyDir = path.join(root, ".openclaw"); + const { legacyDir } = getStateDirMigrationPaths(root); fs.mkdirSync(legacyDir, { recursive: true }); - const result = await autoMigrateLegacyStateDir({ - env: { OPENCLAW_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, - homedir: () => root, - }); + const result = await runStateDirMigration(root, { + OPENCLAW_STATE_DIR: "/custom/state", + } as NodeJS.ProcessEnv); expect(result.skipped).toBe(true); 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 () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); + const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); fs.mkdirSync(path.join(targetDir, "agent"), { recursive: true }); - fs.mkdirSync(legacyDir, { recursive: true }); - const dirLinkType = process.platform === "win32" ? "junction" : "dir"; - fs.symlinkSync(path.join(targetDir, "sessions"), path.join(legacyDir, "sessions"), dirLinkType); - fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), dirLinkType); + fs.symlinkSync( + path.join(targetDir, "sessions"), + path.join(legacyDir, "sessions"), + DIR_LINK_TYPE, + ); + fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), DIR_LINK_TYPE); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); + const result = await runStateDirMigration(root); expect(result.migrated).toBe(false); 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 () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); - fs.mkdirSync(targetDir, { recursive: true }); - fs.mkdirSync(legacyDir, { recursive: true }); + const { targetDir } = ensureLegacyAndTargetStateDirs(root); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); - - expect(result.migrated).toBe(false); - expect(result.warnings).toEqual([ - `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`, - ]); + const result = await runStateDirMigration(root); + expectTargetAlreadyExistsWarning(result, targetDir); }); it("warns when legacy state dir contains non-symlink entries and target already exists", async () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); - fs.mkdirSync(targetDir, { recursive: true }); - fs.mkdirSync(legacyDir, { recursive: true }); + const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); fs.writeFileSync(path.join(legacyDir, "sessions.json"), "{}", "utf-8"); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); - - expect(result.migrated).toBe(false); - expect(result.warnings).toEqual([ - `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`, - ]); + const result = await runStateDirMigration(root); + expectTargetAlreadyExistsWarning(result, targetDir); }); it("does not warn when legacy state dir contains nested symlink mirrors", async () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); + const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); fs.mkdirSync(path.join(targetDir, "agents", "main"), { recursive: true }); - fs.mkdirSync(legacyDir, { recursive: true }); fs.mkdirSync(path.join(legacyDir, "agents"), { recursive: true }); - const dirLinkType = process.platform === "win32" ? "junction" : "dir"; fs.symlinkSync( path.join(targetDir, "agents", "main"), path.join(legacyDir, "agents", "main"), - dirLinkType, + DIR_LINK_TYPE, ); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); + const result = await runStateDirMigration(root); expect(result.migrated).toBe(false); 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 () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); + const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); const outsideDir = path.join(root, ".outside-state"); fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); - fs.mkdirSync(legacyDir, { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true }); - const dirLinkType = process.platform === "win32" ? "junction" : "dir"; - fs.symlinkSync(path.join(outsideDir), path.join(legacyDir, "sessions"), dirLinkType); + fs.symlinkSync(path.join(outsideDir), path.join(legacyDir, "sessions"), DIR_LINK_TYPE); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); - - expect(result.migrated).toBe(false); - expect(result.warnings).toEqual([ - `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`, - ]); + const result = await runStateDirMigration(root); + expectTargetAlreadyExistsWarning(result, targetDir); }); it("warns when legacy state dir contains a broken symlink target", async () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); + const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); 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"); - 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 }); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); - - expect(result.migrated).toBe(false); - expect(result.warnings).toEqual([ - `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`, - ]); + const result = await runStateDirMigration(root); + expectTargetAlreadyExistsWarning(result, targetDir); }); it("warns when legacy symlink escapes target tree through second-hop symlink", async () => { const root = await makeTempRoot(); - const targetDir = path.join(root, ".openclaw"); - const legacyDir = path.join(root, ".clawdbot"); + const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); const outsideDir = path.join(root, ".outside-state"); - fs.mkdirSync(targetDir, { recursive: true }); - fs.mkdirSync(legacyDir, { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true }); - const dirLinkType = process.platform === "win32" ? "junction" : "dir"; const targetHop = path.join(targetDir, "hop"); - fs.symlinkSync(outsideDir, targetHop, dirLinkType); - fs.symlinkSync(targetHop, path.join(legacyDir, "sessions"), dirLinkType); + fs.symlinkSync(outsideDir, targetHop, DIR_LINK_TYPE); + fs.symlinkSync(targetHop, path.join(legacyDir, "sessions"), DIR_LINK_TYPE); - const result = await autoMigrateLegacyStateDir({ - env: {} as NodeJS.ProcessEnv, - homedir: () => root, - }); - - expect(result.migrated).toBe(false); - expect(result.warnings).toEqual([ - `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`, - ]); + const result = await runStateDirMigration(root); + expectTargetAlreadyExistsWarning(result, targetDir); }); });