Ctrl K
ComunidadGuías…

scripts/testing/rail-profile-ui.e2e.test.ts

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

Archivo original ↗Preguntar a KiBot
// This computer's own workspaces on the rail: main's own handlers in a real headless Chrome (testing/one-workspace-fixture.ts
// with `profiles`), this computer's real server swapped between their folders at one address, the rail's own page in a tab of
// its own, and, once My Cloud is home, this computer really lent to My Cloud all along. Every path a person can take is walked
// through the real pages: + and its naming step (Back, Close, Escape, an empty or taken name), switching in every order (a
// turn under way, two quick clicks), a workspace's menu (Rename, Colour, Remove asked first, the open one, the last one), an
// app restart, the rail past eight, the cloud badge in both looks, and a person with no Cloud, whose rail appears with a second
// place. Each picture is the window as the person sees it: the rail's own page over or beside the page the window shows.
// Pictures go to OMB_ONE_WORKSPACE_SHOTS, else the fixture's folder.
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { cleanWorkspaceConfig, profileDir, profilesDir } from "../../electron/clean-workspace.mjs";
import { parseCloudList } from "../../electron/cloud-list.mjs";
import { resolveAgentBrowserBinary } from "../../server/browser-engine.ts";
import { hostTimeout } from "../../server/testing/host-timeout.ts";
import { freePortBlock } from "../../server/testing/ports.ts";
import { launchCloudSendFixture, type CloudSendFixture } from "./cloud-send-fixture.ts";
import { ensureUiBrowser, UI_TOOLS_DIR } from "./control-omb-ui.ts";
import { launchHomeWorld, shotsDir, welcomed, type HomeWorld } from "./one-workspace-fixture.ts";
import { decodePng, encodePng } from "./png.ts";

const enabled = process.env.OMB_UI_E2E === "1" || Boolean(resolveAgentBrowserBinary({ dataDir: UI_TOOLS_DIR, env: process.env }));
if (!enabled) console.info("skipping the rail workspaces UI e2e: set OMB_UI_E2E=1 to install the pinned browser");

const MAC = '[data-rail-place="profile:default"]';
const WIDTH = 1280, HEIGHT = 800;

/** One world's helpers: waiting, both pages, the rail's tiles and the window's picture. */
function harness(get: () => { world: HomeWorld; shots: string }) {
  const until = async <T>(read: () => Promise<T>, ok: (value: T) => boolean, timeoutMs = 30_000, what = "the window"): Promise<T> => {
    const deadline = Date.now() + timeoutMs;
    for (;;) {
      const value = await read().catch(() => undefined as T);
      if (value !== undefined && ok(value)) return value;
      if (Date.now() > deadline) {
        await get().world.window.screenshot(join(get().shots, `failure-${Date.now()}.png`)).catch(() => {});
        throw new Error(`timed out waiting for ${what}: ${JSON.stringify(value)} at ${get().world.window.url}`);
      }
      await new Promise((resolve) => setTimeout(resolve, 100));
    }
  };
  const page = <T>(expression: string) => get().world.window.evaluate<T>(expression);
  const rail = <T>(expression: string) => get().world.rail!.evaluate<T>(expression);
  const tiles = () => rail<Array<{ id: string; label: string; current: boolean; cloud: boolean; local: boolean }>>(
    "[...document.querySelectorAll('[data-rail-place]')].map((tile) => ({ id: tile.dataset.railPlace, label: tile.getAttribute('aria-label'), current: tile.getAttribute('aria-current') === 'page', cloud: Boolean(tile.querySelector('[data-rail-cloud]')), local: Boolean(tile.querySelector('[data-rail-local]')) }))");
  const onLocal = () => /^https?:/.test(get().world.window.url) && new URL(get().world.window.url).origin === get().world.localOrigin;
  /** The window as the person sees it: the rail at its left edge beside the page, or, while a sheet of the rail's is open,
   * the rail's see-through view over the whole window. */
  const picture = async (name: string) => {
    const { world, shots } = get();
    await new Promise((resolve) => setTimeout(resolve, 400));
    await world.rail!.screenshot(join(shots, `${name}-rail.png`));
    await world.window.screenshot(join(shots, `${name}-page.png`));
    const over = decodePng(readFileSync(join(shots, `${name}-rail.png`))), under = decodePng(readFileSync(join(shots, `${name}-page.png`)));
    const covers = world.railCovers(), width = covers ? under.width : over.width + under.width, height = Math.max(over.height, under.height);
    writeFileSync(join(shots, `${name}.png`), encodePng(width, height, (x, y) => {
      const base = covers ? (y < under.height ? under.pixel(x, y) : [255, 255, 255] as [number, number, number])
        : x < over.width ? [255, 255, 255] as [number, number, number] : y < under.height ? under.pixel(x - over.width, y) : [255, 255, 255] as [number, number, number];
      if (x >= over.width || y >= over.height) return base;
      const top = over.pixel(x, y), alpha = over.alpha(x, y) / 255;
      return [0, 1, 2].map((at) => Math.round(top[at]! * alpha + base[at]! * (1 - alpha))) as [number, number, number];
    }));
    for (const part of ["rail", "page"]) rmSync(join(shots, `${name}-${part}.png`), { force: true });
  };
  /** A click on the rail's page, once React has drawn what is clicked. */
  const click = (selector: string) => until(() => rail<boolean>(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`),
    Boolean, 10_000, selector);
  /** A right-click on a tile, then its menu as React drew it. */
  const menu = async (id: string) => {
    await rail(`document.querySelector('[data-rail-place="${id}"]').dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }))`);
    await until(() => rail<boolean>(`Boolean(document.querySelector('[data-rail-menu="${id}"]'))`), Boolean, 10_000, `${id}'s menu`);
  };
  /** Typing as a person does into a field of the rail's page (React's own value setter, then its input event). */
  const fill = (selector: string, value: string) => rail(`(() => { const input = document.querySelector(${JSON.stringify(selector)});
    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(value)}); input.dispatchEvent(new Event("input", { bubbles: true })); return true; })()`);
  const key = (target: "rail" | "page", name: string) => (target === "rail" ? rail : page)(`(document.activeElement ?? document.body).dispatchEvent(new KeyboardEvent("keydown", { key: ${JSON.stringify(name)}, bubbles: true })), window.dispatchEvent(new KeyboardEvent("keydown", { key: ${JSON.stringify(name)}, bubbles: true }))`);
  const bots = () => page<string[]>("fetch('/api/bots?messages=0').then((response) => response.json()).then((body) => body.bots.map((bot) => bot.name))");
  return { until, page, rail, tiles, onLocal, picture, click, menu, fill, key, bots };
}

(enabled ? describe : describe.skip)("workspaces of this computer's own on the rail, once My Cloud is home", () => {
  let fx: CloudSendFixture;
  let world: HomeWorld;
  let shots = "";
  let cloudEntry = "";
  const gate = join(mkdtempSync(join(tmpdir(), "omb-rail-gate-")), "finish");
  writeFileSync(gate, "");
  const h = harness(() => ({ world, shots }));
  const { until, page, rail, tiles, onLocal, picture, click, menu, fill, key, bots } = h;
  /** Every look at the lent link and at the server's folder while the window switched. */
  const linkSeen: boolean[] = [];
  const foldersSeen = new Set<string>();
  let watching = false, watcher: Promise<void> = Promise.resolve();
  const myCloud = () => world.environments().environments.find((entry: { origin?: string }) => entry.origin === world.cloudOrigin);
  const lent = () => Boolean(world.lending!.cloudState(myCloud()).connected);
  const onCloud = () => until(() => page<string>("document.body.innerText"), (text) => new URL(world.window.url).origin === world.cloudOrigin && text.includes("Atlas"), 60_000, "My Cloud's page with its bots");
  const lentCount = async () => ((await fx.onCloud("GET", "/api/shared-computers")).body?.computers ?? []).filter((computer: { online?: boolean }) => computer.online !== false).length;
  const current = async () => (await tiles()).find((tile) => tile.current)?.id ?? null;
  const shown = (id: string, text?: string) => until(async () => ({ current: await current(), local: onLocal(), body: await page<string>("document.body.innerText") }),
    (seen) => seen.current === id && (id === cloudEntry ? !seen.local : seen.local) && (!text || seen.body.includes(text)), 90_000, `${id} on screen${text ? ` with ${text}` : ""}`);
  const lineOf = () => page<string | null>("document.querySelector('[data-home-line]')?.textContent ?? null");
  const workId = () => world.registry!.list().find((entry) => entry.name === "Work")?.id ?? "work";

  beforeAll(async () => {
    fx = await launchCloudSendFixture({ onePasswordToken: `ops_fake-profile-${randomBytes(12).toString("hex")}`,
      desktopEngine: `process.env.FAKE_CLAUDE_MODE = "slow"; process.env.FAKE_CLAUDE_SLOW_FINISH_GATE = ${JSON.stringify(gate)};` });
    await welcomed(fx);
    // What a move leaves: the bots on My Cloud, this computer's copy of them frozen.
    for (const name of ["Atlas", "Juno"]) {
      await fx.local("POST", "/api/bots", { name, modelSelection: { instanceId: "claude", model: "claude-sonnet-5" } });
      await fx.onCloud("POST", "/api/bots", { name, modelSelection: { instanceId: "claude", model: "claude-sonnet-5" } });
    }
    const tools = await ensureUiBrowser(process.env);
    if (!tools.chrome) throw new Error("This harness needs the pinned Chrome for Testing");
    const ports = await Promise.all([freePortBlock([0, 1]), freePortBlock([0, 1]), freePortBlock([0]), freePortBlock([0, 1])]) as [number, number, number, number];
    world = await launchHomeWorld(fx, { chrome: tools.chrome, ports, profiles: true });
    shots = shotsDir(fx.root);
    await world.setSize(WIDTH, HEIGHT);
    // The session lists My Cloud as the person's own, as the Admin does: so its circle is a rail even alone, for +.
    // The Admin lists https addresses only; this one's is the fixture's own edge.
    world.account.state = { ...world.account.state, clouds: parseCloudList([{ id: "c-home", name: "My Cloud", color: null, role: "owner", tier: "pro", runsOn: "cloud",
      status: "ready", origin: "https://omb-u-fixture.fly.dev", teamId: null, primary: true }])!.map((cloud) => ({ ...cloud, origin: world.cloudOrigin })) };
    // Each page's dev server bundles what it imports on the first visit, then reloads it once: done before anything counts.
    for (const url of [`${world.localOrigin}/`, `${world.cloudOrigin}/`]) {
      await world.window.loadURL(url).catch(() => {});
      await new Promise((resolve) => setTimeout(resolve, 8_000));
    }
    expect((await fx.local("POST", "/api/move/freeze", { cloud: world.cloudOrigin })).body.frozen).toBe(true);
    const profile = profileDir(fx.desktopDataDir, "default");
    mkdirSync(profile, { recursive: true });
    const { instances } = JSON.parse(readFileSync(join(fx.desktopDataDir, "config.json"), "utf8"));
    writeFileSync(join(profile, "config.json"), JSON.stringify({ ...cleanWorkspaceConfig("en"), instances }));
  }, hostTimeout(300_000));
  afterAll(async () => { watching = false; await watcher; await world?.close(); await fx?.close(); }, hostTimeout(60_000));

  it("My Cloud opens by default: This Mac beside it, a small cloud on My Cloud's tile only, in the light look and the dark; this computer lent to My Cloud", async () => {
    await world.home.show();
    await onCloud();
    cloudEntry = myCloud()!.id;
    world.lending!.start({ maintainer: false });
    await until(async () => lent(), Boolean, 30_000, "this computer lent to My Cloud");
    await until(lentCount, (count) => count === 1, 30_000, "My Cloud to see this computer");
    watching = true;
    watcher = (async () => { while (watching) { linkSeen.push(lent()); foldersSeen.add(world.running()); await new Promise((resolve) => setTimeout(resolve, 50)); } })();
    expect(world.railList().map((place) => place.id)).toEqual(["profile:default", cloudEntry]);
    await world.rail!.loadURL(`${world.localOrigin}/rail`);
    const found = await until(() => tiles(), (seen) => seen.length === 2, 60_000, "the rail's two tiles");
    expect(found).toEqual([
      { id: "profile:default", label: "This Mac: a separate place on this computer, with its own bots. The bots on My Cloud stay there.", current: false, cloud: false, local: false },
      { id: cloudEntry, label: expect.stringMatching(/, Cloud$/), current: true, cloud: true, local: false },
    ]);
    const ring = await rail<{ badge: number; ring: string; tile: number }>(`(() => { const badge = document.querySelector('[data-rail-cloud]'); const tile = badge.parentElement;
      return { badge: badge.getBoundingClientRect().width, ring: getComputedStyle(badge).borderTopColor, tile: tile.getBoundingClientRect().width }; })()`);
    expect(ring.badge, "a small circle at the tile's corner").toBeGreaterThanOrEqual(13);
    expect(ring.badge).toBeLessThan(ring.tile);
    await world.theme(false);
    await onCloud();
    const lightRing = await until(() => rail<string>("getComputedStyle(document.querySelector('[data-rail-cloud]')).borderTopColor"), Boolean, 15_000, "the badge in the light look");
    await picture("01-rail-cloud-badge-light");
    await world.theme(true);
    await until(() => rail<string>("getComputedStyle(document.querySelector('[data-rail-cloud]')).borderTopColor"), (color) => color !== lightRing, 15_000, "the badge's ring in the dark look");
    await picture("01-rail-cloud-badge-dark");
    await world.theme(false);
  }, hostTimeout(180_000));

  it("This Mac opens fresh; the frozen copy stays frozen; My Cloud and back keeps This Mac running and as it was left", async () => {
    await click(MAC);
    await until(lineOf, (text) => Boolean(text?.startsWith("This Mac starts fresh")), 90_000, "This Mac's page");
    expect(world.running()).toBe(profileDir(fx.desktopDataDir, "default"));
    expect((await bots()).filter((name) => ["Atlas", "Juno"].includes(name)), "none of the Cloud's bots, nor the frozen copy's").toEqual([]);
    expect(await page<string>("fetch('/api/bots', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Scout' }) }).then((response) => response.status)")).toBe(201);
    await picture("02-this-mac-fresh");
    await click(`[data-rail-place="${cloudEntry}"]`);
    await onCloud();
    expect(world.clean!.profile(), "This Mac's place still runs").toBe("default");
    await click(MAC);
    await shown("profile:default", "Scout");
  }, hostTimeout(240_000));

  it("+: the choices with New workspace first; Escape, Close and Back each leave cleanly; an empty name can't be made and a taken one says so", async () => {
    const choose = () => until(async () => world.railCovers() && await rail<boolean>("Boolean(document.querySelector('[data-place-add=\"choose\"]'))"), Boolean, 15_000, "+'s choices over the window");
    await click("[data-rail-add]");
    await choose();
    expect(await rail<string[]>("[...document.querySelectorAll('[data-place-add-choice]')].map((choice) => choice.dataset.placeAddChoice)")).toEqual(["local", "server"]);
    expect(await rail<string>("document.querySelector('[data-place-add-choice=\"local\"]').textContent")).toContain("New workspace on this computer");
    await picture("03-plus-choices-light");
    await world.theme(true);
    await picture("03-plus-choices-dark");
    await world.theme(false);
    await key("rail", "Escape");
    await until(async () => !world.railCovers() && !(await rail("Boolean(document.querySelector('[data-place-add-sheet]'))")), Boolean, 10_000, "Escape closing the sheet");
    await click("[data-rail-add]");
    await choose();
    await click('[data-place-add-choice="local"]');
    await until(() => rail("Boolean(document.querySelector('[data-place-add=\"local\"]'))"), Boolean, 10_000, "the naming step");
    await rail("[...document.querySelectorAll('[data-place-add=\"local\"] button')].find((button) => button.textContent === 'Back').click()");
    await choose();
    await click('[data-place-add-sheet] [aria-label="Close"]');
    await until(async () => !world.railCovers(), Boolean, 10_000, "Close closing the sheet");
    expect(world.registry!.list().map((entry) => entry.id), "nothing made").toEqual(["default"]);
  }, hostTimeout(120_000));

  it("+ → New workspace → Work, in a colour: it opens fresh, This Mac keeps its data, and the window and the line call it Work", async () => {
    await click("[data-rail-add]");
    await until(() => rail("Boolean(document.querySelector('[data-place-add-choice=\"local\"]'))"), Boolean, 15_000, "+'s choices");
    await click('[data-place-add-choice="local"]');
    await until(() => rail("Boolean(document.querySelector('[data-place-add=\"local\"]'))"), Boolean, 15_000, "the naming step");
    expect(await rail<string>("document.querySelector('[data-local-name]').value"), "the first name no place here has").toBe("Work");
    await fill("[data-local-name]", "   ");
    expect(await rail<boolean>("document.querySelector('[data-local-create]').disabled"), "no name: nothing to make").toBe(true);
    await fill("[data-local-name]", "this mac");
    await click("[data-local-create]");
    await until(() => rail<string | null>("document.querySelector('[data-local-problem]')?.textContent ?? null"), (text) => text === "Another place here has that name. Choose another.", 10_000, "the taken name said");
    await picture("04-plus-name-taken");
    await fill("[data-local-name]", "Work");
    await click('[data-place-add="local"] [data-swatch="rose"]');
    await picture("04-plus-name-work");
    await click("[data-local-create]");
    await until(async () => !world.railCovers(), Boolean, 15_000, "the sheet closing once made");
    await shown(`profile:${workId()}`);
    await until(lineOf, (text) => text === "Work starts fresh: a separate place on this computer, with its own bots, chats and files.", 30_000, "Work's line");
    await until(() => page<string | null>("document.querySelector('[data-window-place]')?.textContent ?? null"), (name) => name === "Work", 15_000, "the top bar naming Work");
    expect(world.running()).toBe(profileDir(fx.desktopDataDir, workId()));
    expect(await bots(), "fresh: no Scout").not.toContain("Scout");
    const work = (await tiles()).find((tile) => tile.id === `profile:${workId()}`)!;
    expect([work.local, work.cloud, work.label]).toEqual([true, false, "Work: a separate place on this computer, with its own bots"]);
    expect(world.registry!.get(workId())?.color).toBe("rose");
    await picture("05-work-fresh");
  }, hostTimeout(180_000));

  it("This Mac ↔ Work ↔ My Cloud in every order: each shows what was left there, the frozen copy never runs, the link never drops", async () => {
    const work = `profile:${workId()}`;
    foldersSeen.clear();
    const steps: Array<[string, string]> = [["profile:default", "Scout"], [cloudEntry, "Atlas"], [work, ""], [cloudEntry, "Atlas"], ["profile:default", "Scout"], [work, ""], ["profile:default", "Scout"]];
    for (const [id, text] of steps) {
      await click(`[data-rail-place="${id}"]`);
      await shown(id, text || undefined);
      if (id === work) expect(await bots()).not.toContain("Scout");
    }
    expect(foldersSeen.has(fx.desktopDataDir), "switching between workspaces never starts the frozen copy's server").toBe(false);
    expect(JSON.parse(readFileSync(join(fx.desktopDataDir, "moved.json"), "utf8")).cloud).toBe(world.cloudOrigin);
    expect(linkSeen.every(Boolean), "this computer stayed lent to My Cloud throughout").toBe(true);
    expect(await lentCount(), "one lent computer, whichever workspace is open").toBe(1);
  }, hostTimeout(400_000));

  it("a switch while a turn runs: the circle says it opens once the bot finishes, the turn finishes there, and its reply is there on return", async () => {
    await click(`[data-rail-place="profile:${workId()}"]`);
    await shown(`profile:${workId()}`);
    if (!(await bots()).length) await page("fetch('/api/bots', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Chief', modelSelection: { instanceId: 'claude', model: 'claude-sonnet-5' } }) })");
    await world.window.loadURL(`${world.localOrigin}/`);
    const composer = "textarea[aria-label^=\"Message\"]";
    await until(() => page<boolean>(`Boolean(document.querySelector('${composer}'))`), Boolean, 60_000, "Work's composer");
    rmSync(gate, { force: true });
    await page(`document.querySelector('${composer}').focus()`);
    await world.window.cdp.request("Input.insertText", { text: "Sort the quarterly notes" }, world.window.sessionId);
    for (const type of ["keyDown", "keyUp"]) await world.window.cdp.request("Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, world.window.sessionId);
    const busy = await until(async () => (await fetch(`${fx.desktopBase}/api/move/freeze`, { headers: { "x-openmausbot-desktop-owner": fx.ownerCapability } }).then((response) => response.json())).busy as string | undefined,
      (name) => typeof name === "string" && name.length > 0, 30_000, "the turn under way");
    await click(MAC);
    await until(() => tiles(), (seen) => seen.find((tile) => tile.id === "profile:default")?.label === `This Mac opens once ${busy} finishes`, 15_000, "This Mac's circle saying it waits");
    expect(world.running(), "Work still runs while its bot answers").toBe(profileDir(fx.desktopDataDir, workId()));
    await picture("06-switch-waits-for-turn");
    writeFileSync(gate, "");
    await shown("profile:default", "Scout");
    await click(`[data-rail-place="profile:${workId()}"]`);
    await shown(`profile:${workId()}`);
    await until(() => page<string>("document.body.innerText"), (text) => text.includes("reply to:") && text.includes("Sort the quarterly notes"), 30_000, "the reply, there on return");
    expect(await page<string>("document.body.innerText"), "it finished: never cut short").not.toContain("turn interrupted");
  }, hostTimeout(240_000));

  it("two quick clicks on two tiles: only the last opens, and the rail and the window agree", async () => {
    await rail(`document.querySelector('${MAC}').click(); document.querySelector('[data-rail-place="${cloudEntry}"]').click(); document.querySelector('[data-rail-place="profile:${workId()}"]').click();`);
    await shown(`profile:${workId()}`);
    await new Promise((resolve) => setTimeout(resolve, 3_000));
    expect(await current(), JSON.stringify({ list: world.railList(), profile: world.home.profile(), active: world.environments().activeId, url: world.window.url, running: world.running() })).toBe(`profile:${workId()}`);
    expect(world.running()).toBe(profileDir(fx.desktopDataDir, workId()));
    await rail(`document.querySelector('[data-rail-place="profile:${workId()}"]').click(); document.querySelector('${MAC}').click();`);
    await shown("profile:default", "Scout");
    await new Promise((resolve) => setTimeout(resolve, 3_000));
    expect([await current(), world.running()]).toEqual(["profile:default", profileDir(fx.desktopDataDir, "default")]);
  }, hostTimeout(240_000));

  it("a workspace's menu: Rename, its colour, and Remove asked first; Cancel changes nothing; removing the open one goes to My Cloud first", async () => {
    const id = workId(), tile = `profile:${id}`;
    await menu(tile);
    await until(() => rail("Boolean(document.querySelector('[data-rail-menu]'))"), Boolean, 10_000, "Work's menu");
    await picture("07-menu");
    await click("[data-rail-rename]");
    await fill("[data-rail-rename-form] input", "Office");
    await rail("document.querySelector('[data-rail-rename-form]').requestSubmit()");
    await until(() => tiles(), (seen) => seen.find((entry) => entry.id === tile)?.label === "Office: a separate place on this computer, with its own bots", 10_000, "the tile renamed");
    await menu(tile);
    await click('[data-rail-local-color] [data-swatch="violet"]');
    await until(async () => world.registry!.get(id)?.color, (color) => color === "violet", 10_000, "the colour saved");
    await menu(tile);
    await click("[data-rail-remove-ask]");
    await until(() => rail<boolean>("Boolean(document.querySelector('[data-rail-menu] [role=\"alertdialog\"]'))"), Boolean, 10_000, "Remove asking");
    expect(await rail<string>("document.querySelector('[data-rail-menu] [role=\"alertdialog\"] p').textContent"))
      .toBe("Remove “Office”? Its bots, chats and files on this computer are deleted. Its folder goes to the Trash first.");
    await picture("08-remove-asks");
    await click("[data-rail-remove-cancel]");
    await key("rail", "Escape");
    expect(world.registry!.get(id), "Cancel: nothing removed").not.toBeNull();
    await click(`[data-rail-place="${tile}"]`);
    await shown(tile);
    await page("fetch('/api/bots', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Ledger' }) })");
    await menu(tile);
    await click("[data-rail-remove-ask]");
    await click("[data-rail-remove-confirm]");
    await onCloud();
    await until(() => tiles(), (seen) => !seen.some((entry) => entry.id === tile), 30_000, "Office gone from the rail");
    expect(world.running(), "its server stopped before its folder went").not.toBe(profileDir(fx.desktopDataDir, id));
    expect(existsSync(profileDir(fx.desktopDataDir, id))).toBe(false);
    const trashed = readdirSync(world.trashDir).find((name) => name.endsWith(`-${id}`))!;
    expect(readFileSync(join(world.trashDir, trashed, "bots.json"), "utf8"), "its bots are in the Trash, not gone").toContain("Ledger");
    await picture("09-removed-open-one");
  }, hostTimeout(240_000));

  it("an app restart reopens the place open when it closed: Work, then This Mac, each as it was; a workspace removed meanwhile opens My Cloud", async () => {
    await click("[data-rail-add]");
    await until(() => rail("Boolean(document.querySelector('[data-place-add-choice=\"local\"]'))"), Boolean, 15_000, "+'s choices");
    await click('[data-place-add-choice="local"]');
    await until(() => rail("Boolean(document.querySelector('[data-local-name]'))"), Boolean, 15_000);
    await fill("[data-local-name]", "Work");
    await click("[data-local-create]");
    const work = workId();
    await shown(`profile:${work}`);
    await page("fetch('/api/bots', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Diary' }) })");
    await world.restartApp();
    await shown(`profile:${work}`, "Diary");
    expect(world.running(), "Work's server, not the frozen copy's").toBe(profileDir(fx.desktopDataDir, work));
    expect(JSON.parse(readFileSync(join(fx.desktopDataDir, "moved.json"), "utf8")).cloud, "the frozen copy stays frozen").toBe(world.cloudOrigin);
    const after = await until(() => tiles(), (seen) => seen.length === 3, 30_000, "the rail after the restart");
    expect(after.map((tile) => [tile.id, tile.current])).toEqual([["profile:default", false], [`profile:${work}`, true], [cloudEntry, false]]);
    await picture("10-after-restart-work");
    await until(async () => lent(), Boolean, 30_000, "this computer lent again after the restart");
    await click(MAC);
    await shown("profile:default", "Scout");
    await world.restartApp();
    await shown("profile:default", "Scout");
    expect(world.running()).toBe(profileDir(fx.desktopDataDir, "default"));
    expect((await tiles()).map((tile) => [tile.id, tile.current])).toEqual([["profile:default", true], [`profile:${work}`, false], [cloudEntry, false]]);
    await picture("10-after-restart-this-mac");
    // Its line's ✕ closes it for good in This Mac's own config: a reload doesn't bring it back, and Work's still shows.
    await until(() => page<boolean>("(() => { const close = document.querySelector('[data-home-line-close]'); close?.click(); return Boolean(close); })()"), Boolean, 15_000, "the line's ✕");
    await until(lineOf, (text) => text === null, 10_000, "the line closed");
    await world.window.loadURL(`${world.localOrigin}/`);
    await until(() => page<string>("document.body.innerText"), (text) => text.includes("Scout"), 60_000, "This Mac reloaded");
    await new Promise((resolve) => setTimeout(resolve, 2_000));
    expect(await lineOf(), "closed for good").toBeNull();
    await click(`[data-rail-place="profile:${work}"]`);
    await until(lineOf, (text) => Boolean(text?.startsWith("Work starts fresh")), 60_000, "Work's own line, still there");
    await click(MAC);
    await shown("profile:default", "Scout");
    // A workspace removed while the app was closed: My Cloud opens instead, with no dialog, and the next launch doesn't try it again.
    expect(world.profiles!.create({ name: "Spare" }).outcome).toBe("made");
    const spare = world.registry!.list().find((entry) => entry.name === "Spare")!.id;
    await click(`[data-rail-place="profile:${spare}"]`);
    await shown(`profile:${spare}`);
    await world.restartApp(() => { world.registry!.remove(spare); rmSync(profileDir(fx.desktopDataDir, spare), { recursive: true, force: true }); });
    await onCloud();
    expect(world.running(), "the data folder's server, as a launch starts it").toBe(fx.desktopDataDir);
    expect(world.clean!.active()).toBe(false);
    expect(world.places.remembered(), "forgotten").toBeNull();
    expect((await until(() => tiles(), (seen) => seen.length === 3, 30_000, "the rail without Spare")).map((tile) => [tile.id, tile.current]))
      .toEqual([["profile:default", false], [`profile:${work}`, false], [cloudEntry, true]]);
    await picture("10-after-restart-removed");
    await click(`[data-rail-place="profile:${work}"]`);
    await shown(`profile:${work}`, "Diary");
  }, hostTimeout(360_000));

  it("past eight tiles: ⋯ lists every place, a row opens its place and its right-click opens its menu", async () => {
    for (const name of ["Lab", "Studio", "Garden", "Kitchen", "Travel", "Music"]) expect(world.profiles!.create({ name }).outcome).toBe("made");
    await until(() => tiles(), (seen) => seen.length === 8, 30_000, "eight tiles");
    expect(await rail<boolean>("Boolean(document.querySelector('[data-rail-more-button]'))")).toBe(true);
    await click("[data-rail-more-button]");
    await until(() => rail<number>("document.querySelectorAll('[data-rail-more] li').length"), (count) => count === world.railList().length && count > 8, 10_000, "every place in the list");
    await picture("11-past-eight");
    await rail("[...document.querySelectorAll('[data-rail-more] li button')].find((row) => row.textContent.includes('Music')).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))");
    await until(() => rail("Boolean(document.querySelector('[data-rail-menu] [data-rail-rename]'))"), Boolean, 10_000, "Music's menu from its row");
    await key("rail", "Escape");
    await click("[data-rail-more-button]");
    await rail("[...document.querySelectorAll('[data-rail-more] li button')].find((row) => row.textContent.includes('Music')).click()");
    const music = world.registry!.list().find((entry) => entry.name === "Music")!.id;
    await shown(`profile:${music}`);
    expect((await tiles()).some((tile) => tile.id === `profile:${music}`), "the place on screen is always among the tiles").toBe(true);
  }, hostTimeout(240_000));

  it("removing the last workspace of this computer's own lands on My Cloud, whose + still offers a new one", async () => {
    for (const entry of world.registry!.list()) {
      const on = await current();
      if (on === `profile:${entry.id}`) {
        await menu(`profile:${entry.id}`);
        await click("[data-rail-remove-ask]");
        await click("[data-rail-remove-confirm]");
        await onCloud();
      } else expect(await world.profiles!.remove(entry.id)).toEqual({ outcome: "removed" });
    }
    await until(() => tiles(), (seen) => seen.length === 1 && seen[0]!.id === cloudEntry, 30_000, "My Cloud alone on the rail");
    expect(world.running()).toBe(fx.desktopDataDir);
    expect(readdirSync(profilesDir(fx.desktopDataDir)).filter((name) => !name.startsWith(".") && name !== "profiles.json")).toEqual([]);
    await click("[data-rail-add]");
    await until(() => rail("Boolean(document.querySelector('[data-place-add-choice=\"local\"]'))"), Boolean, 15_000, "+'s choices");
    await click('[data-place-add-choice="local"]');
    await until(() => rail("Boolean(document.querySelector('[data-local-name]'))"), Boolean, 15_000, "+ still offering a new workspace");
    expect(await rail<string>("document.querySelector('[data-local-name]').value"), "This Mac's name is free again").toBe("Work");
    await key("rail", "Escape");
    await picture("12-last-removed");
    watching = false; await watcher;
    expect(linkSeen.length).toBeGreaterThan(100);
    expect(linkSeen.every(Boolean), "the lent link never dropped, from the first switch to the last removal").toBe(true);
    expect(JSON.parse(readFileSync(join(fx.desktopDataDir, "moved.json"), "utf8")).cloud, "never unfrozen: its routines never ran beside a workspace's").toBe(world.cloudOrigin);
  }, hostTimeout(180_000));
});

(enabled ? describe : describe.skip)("a person with no Cloud: a new workspace from the Server menu, and the rail once there are two places", () => {
  let fx: CloudSendFixture;
  let world: HomeWorld;
  let shots = "";
  const { until, page, tiles, onLocal, picture, click, menu, key, bots } = harness(() => ({ world, shots }));

  beforeAll(async () => {
    fx = await launchCloudSendFixture({ onePasswordToken: `ops_fake-local-${randomBytes(12).toString("hex")}` });
    await welcomed(fx);
    await fx.local("POST", "/api/bots", { name: "Atlas", modelSelection: { instanceId: "claude", model: "claude-sonnet-5" } });
    const tools = await ensureUiBrowser(process.env);
    if (!tools.chrome) throw new Error("This harness needs the pinned Chrome for Testing");
    const ports = await Promise.all([freePortBlock([0, 1]), freePortBlock([0, 1]), freePortBlock([0]), freePortBlock([0, 1])]) as [number, number, number, number];
    world = await launchHomeWorld(fx, { chrome: tools.chrome, ports, profiles: true });
    shots = shotsDir(fx.root);
    await world.setSize(WIDTH, HEIGHT);
    world.moved = false;
    world.account.state = { status: "signed-out" } as never;
    await world.window.loadURL(`${world.localOrigin}/`).catch(() => {});
    await new Promise((resolve) => setTimeout(resolve, 8_000));
  }, hostTimeout(300_000));
  afterAll(async () => { await world?.close(); await fx?.close(); }, hostTimeout(60_000));

  it("one place, no rail; the Server menu's New workspace asks a name on this computer's page, Escape leaves, Create makes it and the rail appears", async () => {
    await world.home.show();
    await until(async () => onLocal() && (await bots()).includes("Atlas"), Boolean, 60_000, "This computer's page");
    expect(world.railList()).toEqual([]);
    await world.rail!.loadURL(`${world.localOrigin}/rail`);
    await new Promise((resolve) => setTimeout(resolve, 3_000));
    expect(await tiles()).toEqual([]);
    world.window.webContents.send("workspaces:new-local");
    await until(() => page("Boolean(document.querySelector('[data-place-add=\"local\"]'))"), Boolean, 15_000, "the naming step on this computer's page");
    await picture("L1-server-menu-name-step");
    await key("page", "Escape");
    await until(() => page("!document.querySelector('[data-place-add-sheet]')"), Boolean, 10_000, "Escape closing it");
    expect(world.registry!.list().filter((entry) => entry.name)).toEqual([]);
    world.window.webContents.send("workspaces:new-local");
    await until(() => page("Boolean(document.querySelector('[data-local-create]'))"), Boolean, 15_000);
    await page("document.querySelector('[data-local-create]').click()");
    await until(() => tiles(), (seen) => seen.map((tile) => [tile.id, tile.current]).join() === "local,false,profile:work,true", 90_000, "This computer and Work on the rail, Work on screen");
    expect(world.running()).toBe(profileDir(fx.desktopDataDir, "work"));
    expect(await bots()).not.toContain("Atlas");
    await until(() => page<string | null>("document.querySelector('[data-home-line]')?.textContent ?? null"), (text) => Boolean(text?.startsWith("Work starts fresh")), 30_000, "Work's line");
    await picture("L2-rail-appears");
  }, hostTimeout(240_000));

  it("This computer and Work both ways, each as it was; removing the open one goes back to This computer and the rail goes with it", async () => {
    await click('[data-rail-place="local"]');
    await until(async () => (await bots()).includes("Atlas") && world.running() === fx.desktopDataDir, Boolean, 90_000, "This computer again");
    expect((await tiles()).find((tile) => tile.current)?.id).toBe("local");
    await click('[data-rail-place="profile:work"]');
    await until(async () => world.running() === profileDir(fx.desktopDataDir, "work") && !(await bots()).includes("Atlas"), Boolean, 90_000, "Work again");
    await menu("profile:work");
    await click("[data-rail-remove-ask]");
    await click("[data-rail-remove-confirm]");
    await until(async () => onLocal() && world.running() === fx.desktopDataDir && (await bots()).includes("Atlas"), Boolean, 90_000, "This computer after the removal");
    await until(() => tiles(), (seen) => seen.length === 0, 30_000, "one place again: no rail");
    expect(world.railList()).toEqual([]);
    await picture("L3-back-to-one-place");
  }, hostTimeout(240_000));
});