import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { assertBotHere, botMoveRefusal, createBotMoves, MY_CLOUD_TEAM } from "./bot-moves.ts"; import type { BotRecord } from "./store.ts"; import { WaitManager } from "./waits.ts"; const dirs: string[] = []; afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); const ACME = { id: "team-acme", name: "Acme" }; function waitsFor(settled: string[][]) { const dir = mkdtempSync(join(tmpdir(), "omb-bot-moves-")); dirs.push(dir); return new WaitManager({ file: join(dir, "waits.json"), probes: { stamp: async () => null }, sourceAlive: () => true, botBusy: () => false, post: () => true, followUp: () => undefined, showCard: (wait) => `card-${wait.label}`, settleCard: (wait, how) => { settled.push([wait.label, how]); }, }); } function setup({ busy = false, waits = null as WaitManager | null } = {}) { const bots = new Map>([["scout", { id: "scout", name: "Scout" }], ["pip", { id: "pip", name: "Pip" }]]); const routines = [ { id: "brief", botId: "scout", enabled: true }, { id: "weekly", botId: "scout", enabled: false }, { id: "pips", botId: "pip", enabled: true }, ]; const hooks = [{ id: "hook", botId: "scout", enabled: true }]; const events = [{ id: "mail", botId: "scout", enabled: true }]; const removed: string[] = []; const moves = createBotMoves({ store: { bot: (id) => (bots.get(id) as unknown as BotRecord) ?? null, patchBot: (id, patch) => { const bot = bots.get(id)!; Object.assign(bot, patch); return bot as unknown as BotRecord; }, }, routines: () => ({ listRoutines: () => routines as never, update: (id: string, patch: { enabled?: boolean }) => Object.assign(routines.find((routine) => routine.id === id)!, patch) as never, removeForBot: (botId: string) => { removed.push(botId); for (let i = routines.length - 1; i >= 0; i--) if (routines[i]!.botId === botId) routines.splice(i, 1); } }), webhooks: () => ({ list: () => hooks as never, update: (id: string, patch: unknown) => Object.assign(hooks.find((hook) => hook.id === id)!, patch as object) as never }), appEvents: () => ({ list: () => events as never, update: async (id: string, patch: unknown) => Object.assign(events.find((event) => event.id === id)!, patch as object) as never }), waits: () => waits, busy: () => busy, now: () => 1_000, }); return { moves, bots, routines, hooks, events, removed }; } const on = (list: Array<{ id: string; enabled: boolean }>) => Object.fromEntries(list.map((entry) => [entry.id, entry.enabled])); describe("My Cloud holding a bot while it moves into a team", () => { it("takes no turns and switches off its automations, keeping which were on; holding it again changes nothing", async () => { const { moves, bots, routines, hooks, events } = setup(); expect(await moves.hold("scout", ACME)).toEqual({ movedTo: { team: ACME, state: "moving", at: 1_000, held: ["routine:brief", "webhook:hook", "app-event:mail"] } }); expect([on(routines), on(hooks), on(events)]).toEqual([{ brief: false, weekly: false, pips: true }, { hook: false }, { mail: false }]); expect(() => assertBotHere(bots.get("scout") as never)).toThrow("Scout is moving to Acme. It's ready there in a moment."); expect(() => assertBotHere(bots.get("pip") as never)).not.toThrow(); expect((await moves.hold("scout", ACME) as { movedTo: { held: string[] } }).movedTo.held).toEqual(["routine:brief", "webhook:hook", "app-event:mail"]); expect(await moves.hold("scout", { id: "team-beta", name: "Beta" })).toEqual({ refused: "Scout is moving to Acme. It's ready there in a moment." }); }); it("waits while the bot is answering, and refuses a bot that is gone", async () => { expect(await setup({ busy: true }).moves.hold("scout", ACME)).toEqual({ busy: "Scout" }); expect(await setup().moves.hold("nobody", ACME)).toEqual({ refused: "This bot is no longer here." }); }); it("lets the bot go on as it was when the move stops: what it switched off comes back on, nothing else", async () => { const { moves, bots, routines, hooks, events } = setup(); await moves.hold("scout", ACME); expect(await moves.release("scout")).toEqual({ released: true }); expect([on(routines), on(hooks), on(events)]).toEqual([{ brief: true, weekly: false, pips: true }, { hook: true }, { mail: true }]); expect(bots.get("scout")!.movedTo).toBeUndefined(); expect(() => assertBotHere(bots.get("scout") as never)).not.toThrow(); }); it("keeps the bot as the line that opens it in the team once it arrived: archived, its routines gone, never let go of after", async () => { const { moves, bots, routines, removed } = setup(); await moves.hold("scout", ACME); expect(moves.moved("scout", { id: "team-beta", name: "Beta" })).toEqual({ refused: "This bot is not moving to that team." }); expect(moves.moved("scout", ACME)).toMatchObject({ movedTo: { team: ACME, state: "moved" } }); expect(bots.get("scout")).toMatchObject({ hidden: true, movedTo: { state: "moved" } }); expect([removed, routines.map((routine) => routine.id)]).toEqual([["scout"], ["pips"]]); expect(botMoveRefusal(bots.get("scout") as never)).toBe("Scout lives in Acme now."); expect(await moves.release("scout")).toEqual({ refused: "Scout lives in Acme now." }); expect(moves.moved("scout", ACME)).toMatchObject({ movedTo: { state: "moved" } }); expect(removed, "once").toEqual(["scout"]); expect(moves.moved("pip", ACME)).toEqual({ refused: "This bot is not moving to that team." }); }); it("cancels the moving bot's waits, card and all, so none fires for a bot that lives in the team; other bots keep theirs", async () => { const settled: string[][] = []; const waits = waitsFor(settled); const made = (botId: string, label: string) => waits.create({ botId, threadId: `t-${botId}`, label, condition: { kind: "file_exists", path: `/${label}` }, followUp: "Tell them." }); await made("scout", "the report"); await made("pip", "the build"); const { moves } = setup({ waits }); await moves.hold("scout", ACME); expect(waits.all().map((wait) => wait.botId)).toEqual(["pip"]); expect(settled).toEqual([["the report", "cancelled"]]); await made("scout", "the invoice"); moves.moved("scout", ACME); expect(waits.all().map((wait) => wait.botId)).toEqual(["pip"]); expect(settled).toEqual([["the report", "cancelled"], ["the invoice", "cancelled"]]); }); }); describe("a bot moving from this computer to My Cloud", () => { const MY_CLOUD = { id: MY_CLOUD_TEAM, name: "My Cloud" }; function here() { const bots = new Map>([["scout", { id: "scout", name: "Scout" }], ["pip", { id: "pip", name: "Pip" }], ["maus", { id: "maus", name: "Maus", defaultBot: { v: 1 } }]]); const groups = [{ id: "both", memberIds: ["scout", "pip"], dm: false }, { id: "alone", memberIds: ["scout", "maus"], dm: false }]; const routines = [{ id: "brief", botId: "scout", enabled: true }]; const removed: string[] = []; const moves = createBotMoves({ store: { bot: (id) => (bots.get(id) as unknown as BotRecord) ?? null, patchBot: (id, patch) => Object.assign(bots.get(id)!, patch) as unknown as BotRecord, groups, patchGroup: (id, patch) => Object.assign(groups.find((group) => group.id === id)!, patch) }, routines: () => ({ listRoutines: () => routines as never, update: (id: string, patch: { enabled?: boolean }) => Object.assign(routines.find((routine) => routine.id === id)!, patch) as never, removeForBot: (botId: string) => { removed.push(botId); } }), webhooks: () => null, appEvents: () => null, waits: () => null, busy: () => false, }); return { moves, bots, groups, routines, removed }; } it("keeps its routines here, off, leaves a room some of whose bots stay, and keeps a room all of whose bots left", async () => { const { moves, bots, groups, routines, removed } = here(); await moves.hold("scout", MY_CLOUD); moves.moved("scout", MY_CLOUD); expect(removed, "My Cloud ending brings it back with them").toEqual([]); expect(routines[0]!.enabled).toBe(false); expect(bots.get("scout")).toMatchObject({ hidden: true, movedTo: { state: "moved" } }); expect(groups.find((group) => group.id === "both")!.memberIds, "the room goes on here without it").toEqual(["pip"]); expect(groups.find((group) => group.id === "alone")!.memberIds, "only the helper every place has is left").toEqual(["scout", "maus"]); }); it("is here again as it went if My Cloud ends, its automations left off", async () => { const { moves, bots, routines } = here(); await moves.hold("scout", MY_CLOUD); moves.moved("scout", MY_CLOUD); expect(moves.back("scout")).toEqual({ released: true }); expect(bots.get("scout")).toMatchObject({ hidden: false, movedTo: undefined }); expect(routines[0]!.enabled).toBe(false); expect(moves.back("pip"), "a bot that never left has nothing to come back from").toEqual({ released: true }); }); it("a bot that went into a team never comes back this way", async () => { const { moves } = here(); await moves.hold("pip", ACME); moves.moved("pip", ACME); expect("refused" in moves.back("pip")).toBe(true); }); });