Ctrl K
ComunidadGuías…

electron/cloud-send-move.node-test.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import assert from "node:assert/strict";
import test from "node:test";

import { EMPTY, itemKey } from "./cloud-send-parts.mjs";
import { createCloudSend, sendNotice } from "./cloud-send.mjs";
import { manifestGaps, noGaps } from "./cloud-send-check.mjs";
import { installCloudSend, openOwnerSession } from "./cloud-send-main.mjs";

const CLOUD = "https://omb-u-1.fly.dev";
const LOCAL = "http://127.0.0.1:4100";
const summary = (items, extra = {}) => ({ v: 1, appVersion: "0.1.120", items, chats: 1, ...extra });
const bot = (id, parts) => ({ key: itemKey.bot(id), kind: "bot", label: id, id, parts });
const json = (value, status = 200) => new Response(JSON.stringify(value), { status });
const reply = (status, body) => ({ status, ok: status < 400, body });
const stored = (value = null) => ({ read: () => value, write(next) { value = structuredClone(next); }, get value() { return value; } });

/**
 * This computer (`here`: its items, bots, connections and conversations) and a My Cloud that keeps what it is sent
 * (`there`: items by key, so a bot it makes takes its defaults until its fields arrive). `takesMove`: My Cloud says it
 * takes a move. `rowIds`: how many rows each of this computer's conversations has (one by default). Every call to My Cloud
 * is in `calls`.
 */
function sides({ here = [], bots = {}, there = [], connections = [], secrets = {}, takesMove = true, conversations = [], rowIds = {}, onCall = () => {} } = {}) {
  const calls = [];
  const cloud = new Map(there.map((item) => [item.key, structuredClone(item)]));
  const session = {
    call: async (method, route, body) => {
      calls.push(`${method} ${route}`);
      const intercepted = await onCall(method, route, body, cloud);
      if (intercepted) return intercepted;
      if (route === "/.well-known/openmausbot/environment") return reply(200, { capabilities: takesMove ? { cloudHome: true, move: 1 } : { cloudHome: true } });
      if (route.startsWith("/api/cloud-send?item=")) {
        const key = decodeURIComponent(route.slice("/api/cloud-send?item=".length).replace(/&move=1$/u, ""));
        return reply(200, summary([...cloud.values()].filter((item) => item.key === key || (item.kind === "skill" && key === itemKey.bot(item.bot)))));
      }
      if (route === "/api/cloud-send" || route === "/api/cloud-send?move=1") return reply(200, summary([...cloud.values()]));
      if (method === "GET" && route === "/api/cloud-send/files") return reply(200, { v: 1, files: [], space: { freeBytes: 1e12, volumeBytes: 1e12 } });
      if (method === "GET" && route === "/api/cloud-send/chats") return reply(200, { v: 1, conversations: [] });
      if (route === "/api/cloud-connections") return reply(200, { computer: { environmentId: "env-cloud", label: "My Cloud" }, connections: [], engines: ["claude"], commands: ["node"] });
      if (method === "POST" && route === "/api/bots") {
        cloud.set(itemKey.bot(body.id), bot(body.id, { soul: "starter", title: EMPTY, name: "n" }));
        return reply(201, { bot: { id: body.id } });
      }
      const patched = /^\/api\/bots\/([\w-]+)$/u.exec(route);
      if (method === "PATCH" && patched) {
        const item = cloud.get(itemKey.bot(patched[1]));
        if (body.soul) item.parts.soul = `h:${body.soul}`;
        return reply(200, {});
      }
      if (route === "/api/cloud-send/chats") return reply(200, { conversations: 0, messages: 0, accepted: body.conversations.map((entry) => entry.threadId), waiting: [], removed: [] });
      return reply(200, {});
    },
    upload: async (route) => { calls.push(`UPLOAD ${route}`); return reply(201, { path: "/data/attachments/x.png" }); },
    close: async () => { calls.push("logout"); },
  };
  const opened = [];
  const asked = [];
  const deps = {
    local: async (route, init) => {
      if (init?.method && init.method !== "GET") { calls.push(`HERE ${init.method} ${route}`); return json({ v: 1, removed: true }); }
      if (route === "/api/cloud-send" || route === "/api/cloud-send?move=1") return json(summary(here));
      if (route.startsWith("/api/cloud-send/memory?bot=")) return json({ v: 1, files: [], journal: [] });
      if (route === "/api/cloud-send/memory") return json({ v: 1, about: { learned: [], removed: [] }, teams: [] });
      if (route === "/api/cloud-send/files") return json({ v: 1, files: [] });
      if (route.startsWith("/api/cloud-send/kind?key=")) return json({ v: 1, dataDir: "/desk/.openmausbot", record: route.includes("routine") ? { enabled: true } : route.includes("app-event") ? { enabled: false } : {} });
      if (route === "/api/cloud-send/chats?attachments=1") return json({ v: 1, dataDir: "/desk/.openmausbot", conversations, attachments: [] });
      if (route === "/api/bots?messages=0") return json({ bots: Object.entries(bots).map(([id, fields]) => ({ id, threadId: `t-${id}`, name: id, ...fields })) });
      if (/^\/api\/bots\/[\w-]+\/skills$/u.test(route)) return json({ skills: [] });
      if (/^\/api\/bots\/[\w-]+\/memory$/u.test(route)) return json({ topics: [] });
      if (route.includes("/memory/file")) return json({ exists: false, text: "" });
      if (route === "/api/cloud-connections") return json({ computer: { environmentId: "env-desk", label: "Amit's Mac" }, connections, engines: [], commands: [] });
      if (route === "/api/cloud-send/chats") return json({ v: 1, dataDir: "/desk/.openmausbot", conversations });
      const listed = /^\/api\/cloud-send\/chats\/ids\?thread=([\w-]+)&from=(\d+)$/u.exec(route);
      if (listed) {
        const all = Array.from({ length: rowIds[listed[1]] ?? 1 }, (_, i) => `${listed[1]}-${i + 1}`), from = Number(listed[2]);
        return json({ v: 1, ids: all.slice(from, from + 5_000), path: all.slice(from, from + 5_000), leaf: all.at(-1), more: from + 5_000 < all.length });
      }
      const rows = /^\/api\/cloud-send\/chats\/rows\?thread=([\w-]+)&after=(\d+)/u.exec(route);
      if (rows) return json({ rows: [{ id: `${rows[1]}-1`, at: 1 }], leaf: `${rows[1]}-1`, task: null, last: 1, lastId: `${rows[1]}-1`, more: false, restarted: false, attachments: [] });
      return json({}, 404);
    },
    cloud: { origin: () => CLOUD, asleep: () => false, read: async () => json(summary([...cloud.values()])), open: async (options) => { opened.push(options ?? null); return session; } },
    handoff: async (what) => {
      asked.push(what);
      if (what.kind === "connection") return secrets[what.id];
      if (what.kind === "connection-release") return { release: { released: true } };
      return {};
    },
    store: stored(),
    engineWaitMs: 0,
    patience: { stallMs: 0, waits: [0], blips: [] },
  };
  return { calls, cloud, deps, opened, asked };
}

test("a move carries everything: what was once kept here goes, what was removed on My Cloud stays removed, and apps and keys go before the bots", async () => {
  const off = { [itemKey.bot("ember")]: "you", [itemKey.bot("juno")]: "removed" };
  const make = () => sides({
    here: [bot("ember", { soul: "s1" }), bot("juno", { soul: "s2" })], bots: { ember: { soul: "s1" }, juno: { soul: "s2" } },
    connections: [{ id: "key:openrouter", kind: "key", label: "OpenRouter", for: ["openrouter"] }],
    secrets: { "key:openrouter": { connection: { config: { openrouter: { key: "sk-or-v1-fake" } } } } },
  });
  const moving = make();
  moving.deps.store = stored({ v: 1, clouds: { [CLOUD]: { off } } });
  const outcome = await createCloudSend(moving.deps).send({ reason: "move" });
  assert.deepEqual(outcome.result.failed, []);
  assert.deepEqual(outcome.result.bots, ["ember"], "kept here once, it goes now; removed on My Cloud, it never comes back");
  assert.equal(outcome.result.connections, 1);
  const key = moving.calls.indexOf("PATCH /api/config"), first = moving.calls.indexOf("POST /api/bots");
  assert.ok(key >= 0 && key < first, `the key went before the first bot: ${moving.calls.join(", ")}`);
  assert.deepEqual(moving.opened, [{ move: true }]);

  const sending = make();
  sending.deps.store = stored({ v: 1, clouds: { [CLOUD]: { off } } });
  const button = await createCloudSend(sending.deps).send();
  assert.deepEqual(button.result.bots, [], "Send everything still keeps here what the person kept here");
  assert.ok(!sending.calls.includes("PATCH /api/config"), "only a move carries apps and keys");
  assert.deepEqual(sending.opened, [null]);
});

test("a move goes only to a Cloud that says it takes one, and touches nothing on one that doesn't", async () => {
  const old = sides({ here: [bot("ember", { soul: "s1" })], bots: { ember: { soul: "s1" } }, takesMove: false });
  await assert.rejects(createCloudSend(old.deps).send({ reason: "move" }), /^CloudSendError: My Cloud is updating\. Your bots move there by themselves when it's done\.$/u);
  assert.deepEqual(old.calls.filter((call) => !call.startsWith("GET ")), ["logout"]);
});

test("the first pass leaves a sign-in that refreshes itself here; the final pass carries it, and this computer lets go of its copy", async () => {
  const linear = { name: "linear", server: { type: "http", url: "https://mcp.linear.example.test/mcp", headers: {}, enabled: true }, signIn: { url: "https://mcp.linear.example.test/mcp", state: "signed-in", tokens: { access: "a", refresh: "r" } } };
  const move = sides({ connections: [{ id: "mcp:linear", kind: "mcp", label: "linear", signIn: true }],
    secrets: { "mcp:linear": { connection: { mcp: linear }, digest: "d".repeat(64) } } });
  const send = createCloudSend(move.deps);
  await send.send({ reason: "move" });
  assert.ok(move.calls.includes("POST /api/mcp/servers"), "the server itself goes in the first pass");
  assert.ok(!move.calls.some((call) => call.endsWith("/sign-in")), "its sign-in stays here until this computer stops");
  assert.ok(!move.asked.some((what) => what.kind === "connection-release"));
  await send.send({ reason: "move", final: true });
  assert.ok(move.calls.includes("PUT /api/cloud-connections/mcp/linear/sign-in"));
  assert.deepEqual(move.asked.filter((what) => what.kind === "connection-release").map((what) => what.id), ["mcp:linear"]);
});

test("a bot a move made on My Cloud has all its fields sent again when the move was cut short before they were recorded", async () => {
  let cut = true;
  const move = sides({ here: [bot("ember", { soul: "h:mine", title: "h:boss" })], bots: { ember: { soul: "mine", title: "boss" } },
    onCall: (method, route) => { if (cut && method === "PATCH" && route === "/api/bots/ember") throw new TypeError("fetch failed"); } });
  const send = createCloudSend(move.deps);
  const first = await send.send({ reason: "move" });
  assert.equal(first.result.failed[0]?.key, itemKey.bot("ember"));
  assert.equal(move.cloud.get(itemKey.bot("ember")).parts.soul, "starter", "My Cloud made the bot with its own defaults");
  cut = false;
  move.calls.length = 0;
  await send.send({ reason: "move" });
  assert.ok(move.calls.includes("PATCH /api/bots/ember"), `its fields went this time: ${move.calls.join(", ")}`);
  assert.equal(move.cloud.get(itemKey.bot("ember")).parts.soul, "h:mine");
  assert.ok(!move.calls.includes("POST /api/bots"), "the bot is not made twice");
});

test("each item is recorded from My Cloud's own answer as soon as it went, so a send cut short later never takes it for My Cloud's own change", async () => {
  const store = stored({ v: 1, clouds: { [CLOUD]: { sent: { [itemKey.bot("juno")]: { team: ["p1", "p1"] } } } } });
  let cutAtEnd = true;
  const move = sides({ here: [bot("juno", { team: "p2" })], bots: { juno: { section: "Ops" } }, there: [bot("juno", { team: "p1" })],
    onCall: (method, route, _body, cloud) => {
      // My Cloud files the bot its own way: its own hash for what it took.
      if (method === "PATCH" && route === "/api/bots/juno") { cloud.get(itemKey.bot("juno")).parts.team = "p3"; return reply(200, {}); }
      if (cutAtEnd && method === "GET" && route === "/api/cloud-send?move=1" && cloud.get(itemKey.bot("juno")).parts.team === "p3") throw new TypeError("fetch failed");
    } });
  move.deps.store = store;
  const send = createCloudSend(move.deps);
  await assert.rejects(send.send({ reason: "move" }), /fetch failed/u);
  assert.ok(move.calls.includes(`GET /api/cloud-send?item=${encodeURIComponent(itemKey.bot("juno"))}&move=1`));
  cutAtEnd = false;
  const row = (await send.compare()).rows.find((entry) => entry.key === itemKey.bot("juno"));
  assert.equal(row.state, "on-both", "what My Cloud took is in step, not My Cloud's own change");
});

test("a bot My Cloud made as this computer stopped, before the answer came, is the move's own: all its fields go when it goes on", async () => {
  // Seen in the real app on Oct 22: killed mid-move, My Cloud made "Otto" 70 ms later, and the move went on taking it for
  // My Cloud's own bot: its colour never went, and the check called it missing on every pass.
  let dying = true;
  const move = sides({ here: [bot("ember", { soul: "h:mine" })], bots: { ember: { soul: "mine" } },
    onCall: (method, route, body, cloud) => {
      if (dying && method === "POST" && route === "/api/bots") { cloud.set(itemKey.bot(body.id), bot(body.id, { soul: "starter" })); throw new TypeError("fetch failed"); }
    } });
  await createCloudSend(move.deps).send({ reason: "move" });
  assert.equal(move.cloud.get(itemKey.bot("ember")).parts.soul, "starter", "My Cloud made it, this computer never heard");
  dying = false;
  move.calls.length = 0;
  await createCloudSend(move.deps).send({ reason: "move" });
  assert.equal(move.cloud.get(itemKey.bot("ember")).parts.soul, "h:mine", `this computer's fields went: ${move.calls.join(", ")}`);
  assert.ok(!move.calls.includes("POST /api/bots"), "the bot is not made twice");

  let lost = true;
  const never = sides({ here: [bot("juno", { soul: "h:j" })], bots: { juno: { soul: "j" } },
    onCall: (method, route) => { if (lost && method === "POST" && route === "/api/bots") throw new TypeError("fetch failed"); } });
  await createCloudSend(never.deps).send({ reason: "move" });
  lost = false;
  const again = await createCloudSend(never.deps).send({ reason: "move" });
  assert.deepEqual(again.result.bots, ["juno"], "one My Cloud never made goes now, and never counts as removed there");
  assert.equal(never.cloud.get(itemKey.bot("juno")).parts.soul, "h:j");
});

test("a move keeps My Cloud's version of a part both sides hold otherwise with nothing recorded, and records it, so its check never calls it missing", async () => {
  // The same bot on both sides with no send ever recorded (an earlier whole-workspace Copy, or a lost cloud-send.json),
  // and its approval changed on My Cloud since: §2.2 keeps My Cloud's. Seen in the real app on Oct 22: the check called
  // `access` missing on every pass, so the move froze, thawed and tried again every hour without end.
  const here = [bot("ember", { soul: "s1", access: EMPTY })], there = [bot("ember", { soul: "s1", access: "a-full" })];
  const move = sides({ here, bots: { ember: { soul: "s1" } }, there });
  const send = createCloudSend(move.deps);
  await send.send({ reason: "move" });
  assert.ok(!move.calls.includes("PATCH /api/bots/ember"), `My Cloud's is kept, nothing goes: ${move.calls.join(", ")}`);
  const sent = move.deps.store.value.clouds[CLOUD].sent;
  assert.deepEqual(sent[itemKey.bot("ember")].access, [EMPTY, "a-full"], "the move's decision is recorded");
  assert.equal(noGaps(manifestGaps({ items: here }, { items: there }, { sent })), true, "the check agrees with the plan");

  const button = sides({ here, bots: { ember: { soul: "s1" } }, there });
  await createCloudSend(button.deps).send();
  assert.equal(button.deps.store.value.clouds[CLOUD].sent[itemKey.bot("ember")].access, undefined, "Send everything still leaves it to the person");
});

test("Settings learns how many conversations are still to go, and a pass says how many did not all go", async () => {
  const conversations = ["a", "b", "c"].map((threadId) => ({ bot: "ember", threadId, last: 1, lastId: `${threadId}-1`, busy: threadId === "c" }));
  const move = sides({ here: [bot("ember", { soul: "s" })], there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } }, conversations,
    onCall: (method, route, body) => route === "/api/cloud-send/chats"
      ? reply(200, { conversations: 0, messages: 1, accepted: body.conversations.map((entry) => entry.threadId).filter((id) => id !== "b"), waiting: ["b"], removed: [] }) : null });
  const send = createCloudSend(move.deps);
  assert.deepEqual((await send.compare()).chats, { waiting: 3 });
  const outcome = await send.send({ reason: "move" });
  assert.equal(outcome.result.chats.left, 2, "one a bot is answering in, and one My Cloud did not take yet");
  assert.deepEqual(send.snapshot().chats, { waiting: 2 });
});

test("a move's session counts as someone using My Cloud, so it stays awake; any other session main opens never does", async () => {
  const headers = [];
  const fetchImpl = async (url, init) => { headers.push([String(url).replace(CLOUD, ""), init.headers["x-omb-background"] ?? null, init.body]); return json({ token: "omb_sess_fake" }); };
  const target = { origin: CLOUD, grant: async () => ({ origin: CLOUD, code: "C" }) };
  const moving = await openOwnerSession(target, fetchImpl, { label: "Move to My Cloud", background: false });
  await moving.call("GET", "/api/cloud-send");
  await moving.close();
  const sending = await openOwnerSession(target, fetchImpl);
  await sending.call("GET", "/api/cloud-send");
  assert.deepEqual(headers.map(([route, background]) => [route, background]), [
    ["/api/auth/pair", null], ["/api/cloud-send", null], ["/api/auth/logout", null], ["/api/auth/pair", "1"], ["/api/cloud-send", "1"],
  ]);
  assert.equal(JSON.parse(headers[0][2]).label, "Move to My Cloud");

  const paired = [];
  const handlers = new Map();
  const listing = { computer: { environmentId: "env", label: "Mac" }, connections: [], engines: [], commands: [] };
  const main = installCloudSend({
    ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, cloudPage: () => "local", server: () => null,
    local: async (route) => json(route === "/api/cloud-send" || route === "/api/cloud-send?move=1" ? summary([]) : route === "/api/cloud-connections" ? listing : { v: 1, dataDir: "/d", conversations: [] }),
    home: () => target, cookieFetch: async () => json({}, 401), reconnect: async () => false, power: { asleep: () => false },
    file: () => "/nonexistent/cloud-send.json", window: () => null, localOrigin: () => LOCAL,
    fetch: async (url, init) => {
      const route = String(url).replace(CLOUD, "");
      if (route === "/api/auth/pair") { paired.push([JSON.parse(init.body).label, init.headers["x-omb-background"] ?? null]); return json({ token: "omb_sess_fake" }); }
      return json(route === "/api/cloud-send" || route === "/api/cloud-send?move=1" ? summary([]) : route === "/.well-known/openmausbot/environment" ? { capabilities: { move: 1 } } : route === "/api/cloud-connections" ? listing : {});
    },
  });
  assert.equal((await main.move()).reason, "move");
  assert.deepEqual(paired, [["Move to My Cloud", null]]);
});

test("an app or key that could not move is said with My Cloud's reason, counted, and never stops the bots", async () => {
  const move = sides({ here: [bot("ember", { soul: "s1" })], bots: { ember: { soul: "s1" } },
    connections: [{ id: "key:openrouter", kind: "key", label: "OpenRouter", for: ["openrouter"] }],
    secrets: { "key:openrouter": { connection: { config: { openrouter: { key: "sk-or-v1-fake" } } } } },
    onCall: (method, route) => method === "PATCH" && route === "/api/config" ? reply(400, { error: "this key is not for this Cloud" }) : null });
  const send = createCloudSend(move.deps);
  const outcome = await send.send({ reason: "move" });
  assert.deepEqual(outcome.result.bots, ["ember"]);
  assert.deepEqual(outcome.result.failed, [{ key: "key:openrouter", kind: "connection", label: "OpenRouter", error: "My Cloud said: this key is not for this Cloud", final: true }]);
  assert.deepEqual(sendNotice(send.snapshot()).last.reasons, [{ what: { bots: [], skills: 0, logins: 0, onePassword: false, settings: 0, connections: 1, chats: false },
    error: "My Cloud said: this key is not for this Cloud" }]);
});

test("a move has My Cloud keep as its owner's the lines it already held of this computer's bots and rooms, by row id only, and Send everything never asks", async () => {
  const conversations = [
    { bot: "ember", threadId: "t-ember", last: 1, lastId: "t-ember-1", busy: false },
    { bot: "juno", threadId: "t-juno", last: 1, lastId: "t-juno-1", busy: false },
    { room: "r1", threadId: "t-room", last: 1, lastId: "t-room-1", busy: false },
  ];
  const asked = [];
  const make = () => sides({ here: [bot("ember", { soul: "s" })], there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } }, conversations, rowIds: { "t-ember": 12_001 },
    onCall: (method, route, body) => {
      if (route !== "/api/cloud-send/chats/owner") return null;
      asked.push(body.conversations.map((entry) => [entry.threadId, entry.ids.length, entry.ids[0]]));
      return reply(200, { v: 1, stamped: 1 });
    } });
  const outcome = await createCloudSend(make().deps).send({ reason: "move" });
  assert.deepEqual(outcome.result.failed, []);
  assert.deepEqual(asked, [[["t-ember", 5_000, "t-ember-1"], ["t-ember", 5_000, "t-ember-5001"]], [["t-ember", 2_001, "t-ember-10001"], ["t-room", 1, "t-room-1"]]],
    "Ember's and the room's row ids, at most 10,000 a request; Juno is not on My Cloud");
  asked.length = 0;
  await createCloudSend(make().deps).send();
  assert.deepEqual(asked, [], "only the move asks");
});

test("a move names at most 200 of the conversations My Cloud held a request, never one removed there, and says it when My Cloud refuses", async () => {
  const conversations = [
    ...Array.from({ length: 201 }, (_, i) => ({ bot: "ember", threadId: `t-ember-${i}`, last: 1, lastId: `t-ember-${i}-1`, busy: false })),
    { bot: "ember", threadId: "t-gone", last: 1, lastId: "t-gone-1", busy: false },
  ];
  let refuse = false;
  const asked = [];
  const make = () => {
    const both = sides({ here: [bot("ember", { soul: "s" })], there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } }, conversations,
      onCall: (method, route, body) => {
        if (route !== "/api/cloud-send/chats/owner") return null;
        asked.push(body.conversations.map((entry) => entry.threadId));
        return refuse ? reply(500, { error: "the store is busy" }) : reply(200, { v: 1, stamped: 0 });
      } });
    both.deps.store = stored({ v: 1, clouds: { [CLOUD]: { chats: { "t-gone": "removed" } } } });
    return both;
  };
  assert.deepEqual((await createCloudSend(make().deps).send({ reason: "move" })).result.failed, []);
  assert.deepEqual(asked.map((threads) => threads.length), [200, 1]);
  assert.ok(!asked.flat().includes("t-gone"), "removed on My Cloud, its lines are never named");
  refuse = true;
  const refused = await createCloudSend(make().deps).send({ reason: "move" });
  assert.deepEqual(refused.result.failed.map((failure) => [failure.key, failure.error]), [["chats", "My Cloud said: the store is busy"]]);
});

test("a move sends what only it carries last, after the chats and their settings, switched off there; an app event only in the final pass, which this computer then lets go of", async () => {
  const kind = (key, kindName, parts) => ({ key, kind: kindName, label: key, id: key.split(":")[1], bot: "ember", parts });
  const conversations = [{ bot: "ember", threadId: "a", last: 1, lastId: "a-1", busy: false, settings: { snoozedUntil: 5 } }];
  const put = [];
  const move = sides({ here: [bot("ember", { soul: "s" }), kind("routine:r1", "routine", { def: "d1", runs: "x" }), kind("app-event:e1", "app-event", { def: "d2" }),
    { key: "imported-skill:tidy", kind: "imported-skill", label: "tidy", id: "tidy", parts: { files: "f" } }], there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } }, conversations,
  onCall: (method, route, body, cloud) => {
    if (method === "PUT" && route === "/api/cloud-send/kind") { put.push(body.key); cloud.set(body.key, { key: body.key, kind: body.key.split(":")[0], label: body.key, id: body.key.split(":")[1], parts: body.key === "routine:r1" ? { def: "d1", runs: "x" } : body.key === "app-event:e1" ? { def: "d2" } : { files: "f" } }); return reply(200, { v: 1, ok: true }); }
    if (method === "GET" && route === "/api/cloud-send/chats") return reply(200, { v: 1, conversations: [{ threadId: "a", settings: {} }] });
    if (method === "POST" && route === "/api/cloud-send/chats/settings") return reply(200, { v: 1, changed: body.conversations.length });
    return null;
  } });
  const send = createCloudSend(move.deps);
  const first = await send.send({ reason: "move" });
  assert.deepEqual(first.result.failed, []);
  assert.deepEqual(put, ["imported-skill:tidy", "routine:r1"], "an app event waits for the final pass");
  assert.deepEqual(send.switches(), { "routine:r1": true });
  const at = (call) => move.calls.indexOf(call);
  assert.ok(at("PUT /api/cloud-send/kind") > -1 && move.calls.lastIndexOf("PUT /api/cloud-send/kind") > at("POST /api/cloud-send/chats"), "the routine went after the chats");
  assert.ok(move.calls.lastIndexOf("PUT /api/cloud-send/kind") > at("POST /api/cloud-send/chats/settings"), "and after their settings");
  assert.ok(at("PUT /api/cloud-send/kind") < at("POST /api/cloud-send/chats"), "a skill goes before the chats");
  assert.equal(first.result.conversationSettings, 1);
  assert.equal(first.result.moved, 2);
  assert.ok(!move.calls.some((call) => call.startsWith("HERE ")), "this computer lets go of nothing yet");

  const final = await send.send({ reason: "move", final: true });
  assert.deepEqual(final.result.failed, []);
  assert.deepEqual(put.slice(2), ["app-event:e1"]);
  assert.ok(move.calls.includes(`HERE DELETE /api/cloud-send/kind?key=${encodeURIComponent("app-event:e1")}`));
  assert.equal(final.result.moved, 1);
  assert.deepEqual(move.deps.store.value.clouds[CLOUD].on, { "routine:r1": true, "app-event:e1": false }, "what was on here is kept for the switch, after this computer let go");
});

test("an app event My Cloud refuses for good is left behind as final, and this computer keeps collecting it", async () => {
  const event = { key: "app-event:e1", kind: "app-event", label: "Reply to email", id: "e1", bot: "ember", parts: { def: "d2" } };
  const error = "This app automation stays on the computer it came from: connected apps are not set up on My Cloud. Set them up here, then make it again.";
  const refusing = (body) => sides({ here: [bot("ember", { soul: "s" }), event], there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } },
    onCall: (method, route) => method === "PUT" && route === "/api/cloud-send/kind" ? reply(409, body) : null });
  const move = refusing({ error, final: true });
  const final = await createCloudSend(move.deps).send({ reason: "move", final: true });
  assert.deepEqual(final.result.failed, [{ key: "app-event:e1", kind: "app-event", label: "Reply to email", error: `My Cloud said: ${error}`, final: true }]);
  assert.ok(!move.calls.some((call) => call.startsWith("HERE DELETE")), "this computer did not let go of it");
  const busy = refusing({ error: "busy" });
  assert.equal((await createCloudSend(busy.deps).send({ reason: "move", final: true })).result.failed[0].final, undefined, "a refusal that may pass is tried again");
});

test("what a move carries last is never taken for removed on My Cloud, so a routine changed here before the final pass still goes", async () => {
  // Seen in the real app on Oct 22: after a plain move, cloud-send.json had every routine and webhook it had just carried
  // as "removed", because the pass's closing plan read My Cloud before the automations went.
  const routine = (def) => ({ key: "routine:r1", kind: "routine", label: "Digest", id: "r1", bot: "ember", parts: { def } });
  const here = [bot("ember", { soul: "s" }), routine("d1")];
  const put = [];
  const move = sides({ here, there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } },
    onCall: (method, route, body, cloud) => {
      if (method === "PUT" && route === "/api/cloud-send/kind") { put.push(body.key); cloud.set(body.key, here.find((item) => item.key === body.key)); return reply(200, { v: 1, ok: true }); }
      return null;
    } });
  const send = createCloudSend(move.deps);
  await send.send({ reason: "move" });
  assert.deepEqual(put, ["routine:r1"]);
  assert.equal(move.deps.store.value.clouds[CLOUD].off["routine:r1"], undefined, "it went, it is not removed there");
  here[1] = routine("d2");
  await send.send({ reason: "move", final: true });
  assert.deepEqual(put, ["routine:r1", "routine:r1"], "changed here since, it goes again in the final pass");
});

test("a routine on both sides whose only difference is its runs goes in a move, so the runs made here reach My Cloud; one changed on both keeps My Cloud's", async () => {
  // Seen in the real app on Oct 22: a computer whose workspace My Cloud already held (an earlier Copy) kept running a
  // routine; the move kept My Cloud's run list, and its check found "1 runs missing" on every pass, so it never finished.
  const routine = (def, runs) => ({ key: "routine:r1", kind: "routine", label: "Digest", id: "r1", bot: "ember", parts: { def, runs } });
  const make = (here, there) => {
    const put = [];
    const move = sides({ here: [bot("ember", { soul: "s" }), here], there: [bot("ember", { soul: "s" }), there], bots: { ember: { soul: "s" } },
      onCall: (method, route, body) => { if (method === "PUT" && route === "/api/cloud-send/kind") { put.push(body.key); return reply(200, { v: 1, ok: true }); } return null; } });
    return { move, put };
  };
  const runsOnly = make(routine("d1", "runs-here"), routine("d1", "runs-there"));
  await createCloudSend(runsOnly.move.deps).send({ reason: "move" });
  assert.deepEqual(runsOnly.put, ["routine:r1"], "its runs go: My Cloud adds them by id");

  const both = make(routine("d-here", "runs-here"), routine("d-there", "runs-there"));
  await createCloudSend(both.move.deps).send({ reason: "move" });
  assert.deepEqual(both.put, [], "changed on both: My Cloud's routine is kept");
});

test("a bot's fields only a move carries go over My Cloud's move route, absent ones as cleared; any other send never carries them", async () => {
  const fields = [];
  const make = () => sides({ here: [bot("ember", { soul: "s", pinned: "p1", projects: "j1" })], there: [bot("ember", { soul: "s", pinned: "-", projects: "-" })], bots: { ember: { soul: "s" } },
    onCall: (method, route, body) => { if (method === "PUT" && route === "/api/cloud-send/kind") { fields.push(body); return reply(200, { v: 1, ok: true }); } return null; } });
  const moving = make();
  moving.deps.local = ((local) => async (route, init) => route.startsWith("/api/cloud-send/kind?key=")
    ? json({ v: 1, dataDir: "/desk", record: { pinned: true, projects: [{ id: "p1", name: "Launch" }], hidden: true } }) : local(route, init))(moving.deps.local);
  await createCloudSend(moving.deps).send({ reason: "move" });
  assert.deepEqual(fields.map((body) => [body.key, body.record]), [["bot:ember", { pinned: true, projects: [{ id: "p1", name: "Launch" }] }]]);
  fields.length = 0;
  const sending = make();
  await createCloudSend(sending.deps).send({ reason: "button" });
  assert.deepEqual(fields, []);
});

test("while the chats go, the move's progress counts chats only: the automations after them never add to it", async () => {
  // Seen in the real app on Oct 22: the line read "5 of 5 chats" for a moment, and what went after the chats counted on.
  const routine = (id) => ({ key: `routine:${id}`, kind: "routine", label: id, id, bot: "ember", parts: { def: id } });
  const conversations = ["a", "b"].map((threadId) => ({ bot: "ember", threadId, last: 1, lastId: `${threadId}-1`, busy: false }));
  const here = [bot("ember", { soul: "s" }), routine("r1"), routine("r2")];
  const move = sides({ here, there: [bot("ember", { soul: "s" })], bots: { ember: { soul: "s" } }, conversations,
    onCall: (method, route, body, cloud) => {
      if (method === "PUT" && route === "/api/cloud-send/kind") { cloud.set(body.key, here.find((item) => item.key === body.key)); return reply(200, { v: 1, ok: true }); }
      return null;
    } });
  const seen = [];
  move.deps.onChange = (snapshot) => { if (snapshot.running?.phase === "chats") seen.push(snapshot.running.progress ?? null); };
  await createCloudSend(move.deps).send({ reason: "move" });
  const counted = seen.filter(Boolean);
  assert.ok(counted.length > 0, "the chats are counted as they go");
  assert.ok(counted.every((progress) => progress.total === 2 && progress.done <= 2), `every count is of the 2 chats: ${JSON.stringify(counted)}`);
  assert.deepEqual(counted.at(-1), { done: 2, total: 2 });
});

test("a check that finds things missing names, for what may stay behind, each short chat's bot and every bot and room by name", async () => {
  // Seen on a customer's PC: the check found things missing pass after pass, and only this computer's log said what.
  const manifest = (side) => ({ v: 1, appVersion: "0.1.140", items: [bot("b1", { soul: "s" }), bot("b2", { soul: "s" })], memory: [], journal: [], learned: [], forgotten: [], teams: [],
    rooms: [{ id: "r1", name: "Launch room", members: ["b1"], threads: ["t-r1"] }], connections: [],
    conversations: [{ threadId: "t-b1", bot: "b1", rows: 1, newest: "t-b1-1", settings: {} }, { threadId: "t-b2", bot: "b2", rows: 1, newest: "t-b2-1", settings: {} }],
    attachments: side === "here" ? ["0b6c9f2e-1d4a.mp3"] : [], files: [], automations: [], runs: [] });
  const s = sides({ here: [bot("b1", { soul: "s" }), bot("b2", { soul: "s" })], there: [bot("b1", { soul: "s" }), bot("b2", { soul: "s" })], onCall: (method, route, body) => {
    if (route.startsWith("/api/cloud-send/manifest")) return reply(200, { ...manifest("there"), rooms: [{ id: "r1", name: "Launch room", members: ["b1"], threads: ["t-r1"] }] });
    if (route === "/api/cloud-send/chats/verify") {
      return reply(200, { v: 1, conversations: body.conversations.map(({ threadId, ids }) => ({ threadId, missing: threadId === "t-b1" ? ids : [], unstamped: 0,
        path: { holds: true, at: null, leaf: `${threadId}-1`, length: 1 } })) });
    }
    if (route === "/api/cloud-send/chats/attachments") return reply(200, { missing: body.names });
    if (route === "/api/cloud-send/verify-secrets") return reply(200, { v: 1, secrets: {} });
    return null;
  } });
  const local = s.deps.local;
  s.deps.local = async (route, init) => (route === "/api/cloud-send/manifest" ? json(manifest("here")) : local(route, init));
  s.deps.handoff = async (what) => (what.kind === "verify-secrets" ? { secrets: {} } : {});
  const report = await createCloudSend(s.deps).check();
  assert.equal(report.ok, false);
  assert.deepEqual(report.attachments, ["0b6c9f2e-1d4a.mp3"], "one this computer no longer has is missing pass after pass");
  assert.deepEqual(report.names, { b1: "b1", b2: "b2", "room:r1": "Launch room" });
  assert.deepEqual(report.owners, { "t-b1": "b1" }, "only the chats found short");
});