// 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 (read: () => Promise, ok: (value: T) => boolean, timeoutMs = 30_000, what = "the window"): Promise => { 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 = (expression: string) => get().world.window.evaluate(expression); const rail = (expression: string) => get().world.rail!.evaluate(expression); const tiles = () => rail>( "[...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(`(() => { 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(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("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(); let watching = false, watcher: Promise = 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("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("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("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("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("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("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(document.querySelector('[data-place-add=\"choose\"]'))"), Boolean, 15_000, "+'s choices over the window"); await click("[data-rail-add]"); await choose(); expect(await rail("[...document.querySelectorAll('[data-place-add-choice]')].map((choice) => choice.dataset.placeAddChoice)")).toEqual(["local", "server"]); expect(await rail("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("document.querySelector('[data-local-name]').value"), "the first name no place here has").toBe("Work"); await fill("[data-local-name]", " "); expect(await rail("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("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("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(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("document.body.innerText"), (text) => text.includes("reply to:") && text.includes("Sort the quarterly notes"), 30_000, "the reply, there on return"); expect(await page("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(document.querySelector('[data-rail-menu] [role=\"alertdialog\"]'))"), Boolean, 10_000, "Remove asking"); expect(await rail("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("(() => { 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("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(document.querySelector('[data-rail-more-button]'))")).toBe(true); await click("[data-rail-more-button]"); await until(() => rail("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("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("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)); });