import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import type { ReactNode } from "react"; import type { Root } from "react-dom/client"; GlobalRegistrator.register(); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const { act, useState } = await import("react"); const { createRoot } = await import("react-dom/client"); const { RunsOnMenuBody, RunsOnNotice, VisibilityMenuBody, checkedRigValue, hasRunsOnChoices, runsOnAttention, runsOnSummary, } = await import("./new-session-settings-menu"); const { DropdownMenu, DropdownMenuContent } = await import("../../../ui/dropdown-menu"); const { emptySessionDraft } = await import("../../lib/session-create"); afterAll(() => GlobalRegistrator.unregister()); let container: HTMLDivElement; let root: Root; beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); document.body.innerHTML = ""; }); /** Render a drill-in where it lives: inside the open "+" dropdown. */ async function renderInMenu(body: ReactNode) { await act(async () => root.render( {body} , ), ); } function menuRadios() { return [...document.querySelectorAll('[role="menuitemradio"]')]; } function row(name: string) { const found = menuRadios().find((radio) => radio.textContent?.includes(name)); if (!found) throw new Error(`No row named ${name}`); return found; } async function press(key: string, options: KeyboardEventInit = {}) { const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options }); await act(async () => { document.activeElement?.dispatchEvent(event); // Radix moves roving focus on a timeout. await new Promise((resolve) => setTimeout(resolve, 0)); }); return event; } const machine = (name: string, state = "online") => ({ sandboxId: `sandbox-${name}`, name, state, os: "linux", arch: "x86_64" }) as never; const rig = (id: string, name: string) => ({ id, name, scope: "workspace" }) as never; function choices(overrides: Record = {}) { return { draft: emptySessionDraft(), machines: [machine("build-01"), machine("old", "offline")], rigs: [rig("rig-node", "Node 22")], workspaceDefaultRigId: "rig-node", selfhostedPrimary: false, fleetLoadFailed: false, selectedChannelId: null, selectionHistory: { projects: [] }, ...overrides, } as Parameters[0]; } test("Runs on shows only when there is something to choose", () => { expect(hasRunsOnChoices(choices())).toBe(true); expect(hasRunsOnChoices(choices({ machines: [], rigs: [] }))).toBe(false); expect(hasRunsOnChoices(choices({ machines: [], rigs: [], selfhostedPrimary: true }))).toBe(true); }); test("the row value names the environment or the machine", () => { expect(runsOnSummary(choices())).toBe("Node 22"); expect(runsOnSummary(choices({ workspaceDefaultRigId: null }))).toBe("Managed sandbox"); const onMachine = { ...emptySessionDraft(), compute: { kind: "machine", sandboxId: "sandbox-build-01", folder: { kind: "root" } }, }; expect(runsOnSummary(choices({ draft: onMachine }))).toBe("build-01"); }); test("without a managed sandbox backend, the non-machine choice is chat only and environments do not apply", async () => { const none = choices({ noManagedSandbox: true }); expect(runsOnSummary(none)).toBe("No computer"); expect(hasRunsOnChoices(choices({ noManagedSandbox: true, machines: [] }))).toBe(false); await act(async () => root.render( {}} onComputeChange={() => {}} onRetryMachines={() => {}} />, ), ); const text = container.textContent ?? ""; expect(text).toContain("No computer"); expect(text).toContain("Chat only"); expect(text).toContain("build-01"); expect(text).not.toContain("Managed sandbox"); expect(text).not.toContain("Set up for you"); expect(text).not.toContain("Sandbox environment"); expect(text).not.toContain("Node 22"); }); test("with a managed sandbox backend, the managed sandbox and its environments stay offered", async () => { await act(async () => root.render( {}} onComputeChange={() => {}} onRetryMachines={() => {}} />, ), ); const text = container.textContent ?? ""; expect(text).toContain("Managed sandbox"); expect(text).toContain("Sandbox environment"); expect(text).not.toContain("No computer"); }); test("picking a machine goes through the explicit compute path; offline ones can't be picked", async () => { const onComputeChange = mock(); await act(async () => root.render( {}} onComputeChange={onComputeChange} onRetryMachines={() => {}} />, ), ); const radios = [...container.querySelectorAll('[role="radio"]')]; const build = radios.find((radio) => radio.textContent?.includes("build-01"))!; const old = radios.find((radio) => radio.textContent?.includes("old"))!; expect(old.disabled).toBe(true); await act(async () => build.click()); expect(onComputeChange).toHaveBeenCalledTimes(1); expect(onComputeChange.mock.calls[0]![0].compute).toEqual({ kind: "machine", sandboxId: "sandbox-build-01", folder: { kind: "root" }, }); }); test("in the + menu, Runs on rows are menu radios that the keyboard reaches", async () => { const onComputeChange = mock(); const onChange = mock(); await renderInMenu( {}} />, ); expect(document.querySelectorAll('[role="radio"]')).toHaveLength(0); // Focus lands on the checked row, so arrow keys work at once. expect(row("Managed sandbox").getAttribute("aria-checked")).toBe("true"); expect(document.activeElement).toBe(row("Managed sandbox")); expect(row("old").hasAttribute("data-disabled")).toBe(true); await press("ArrowDown"); expect(document.activeElement).toBe(row("build-01")); // The offline machine is skipped on the way to the environment rows. await press("ArrowDown"); expect(document.activeElement).toBe(row("Workspace default")); await press("ArrowUp"); await press("Enter"); expect(onComputeChange).toHaveBeenCalledTimes(1); expect(onComputeChange.mock.calls[0]![0].compute).toEqual({ kind: "machine", sandboxId: "sandbox-build-01", folder: { kind: "root" }, }); // Choosing keeps the menu open for the folder or environment below. expect(document.querySelector('[role="menu"]')).not.toBeNull(); }); test("a draft naming the workspace default environment checks Workspace default", async () => { const draft = { ...emptySessionDraft(), rigId: "rig-node" }; expect(checkedRigValue(draft, "rig-node")).toBe(""); expect(checkedRigValue(draft, "rig-other")).toBe("rig-node"); expect(checkedRigValue(emptySessionDraft(), "rig-node")).toBe(""); await renderInMenu( {}} onComputeChange={() => {}} onRetryMachines={() => {}} />, ); const checked = menuRadios().filter((radio) => radio.getAttribute("aria-checked") === "true"); expect(checked.map((radio) => radio.textContent)).toEqual([ expect.stringContaining("Managed sandbox"), expect.stringContaining("Workspace default: Node 22"), ]); }); function MachineFolderFixture({ presentation = "menu", }: { presentation?: "menu" | "dialog" | undefined; }) { const [draft, setDraft] = useState({ ...emptySessionDraft(), compute: { kind: "machine" as const, sandboxId: "sandbox-build-01", folder: { kind: "root" as const }, }, } as ReturnType); return ( {}} /> ); } test("Custom path transfers focus to its field and Tab or Enter returns to menu navigation", async () => { await renderInMenu(); expect(document.activeElement).toBe(row("build-01")); await press("ArrowDown"); expect(document.activeElement).toBe(row("Machine root")); await press("ArrowDown"); expect(document.activeElement).toBe(row("Custom path")); await press("Enter"); const input = document.querySelector( '[aria-label="Custom working directory"]', )!; expect(input).not.toBeNull(); expect(document.activeElement).toBe(input); for (const key of ["ArrowLeft", "ArrowRight", "Home", "End", " ", "b"]) { expect((await press(key)).defaultPrevented).toBe(false); expect(document.activeElement).toBe(input); } // IME confirmation is text editing, not a route out of the field. await press("Enter", { isComposing: true }); expect(document.activeElement).toBe(input); for (const [key, shiftKey] of [ ["Tab", false], ["Tab", true], ["Enter", false], ] as const) { expect((await press(key, { shiftKey })).defaultPrevented).toBe(true); expect(document.activeElement).toBe(row("Custom path")); expect(document.querySelector('[role="menu"]')).not.toBeNull(); // Selecting the already-selected row re-enters the existing field. await press("Enter"); expect(document.activeElement).toBe(input); } await press("Tab"); await press("ArrowUp"); expect(document.activeElement).toBe(row("Machine root")); await press("Enter"); expect(document.querySelector('[aria-label="Custom working directory"]')).toBeNull(); expect(document.activeElement).toBe(row("Machine root")); }); test("Custom path in a dialog focuses its input without taking over native Tab", async () => { await act(async () => root.render()); const custom = [...container.querySelectorAll('[role="radio"]')].find( (radio) => radio.textContent === "Custom path", )!; await act(async () => custom.click()); const input = container.querySelector( '[aria-label="Custom working directory"]', )!; expect(document.activeElement).toBe(input); expect((await press("Tab")).defaultPrevented).toBe(false); }); test("a failed machine load offers a retry the keyboard reaches", async () => { const onRetryMachines = mock(); await renderInMenu( {}} onComputeChange={() => {}} onRetryMachines={onRetryMachines} />, ); const retry = [...document.querySelectorAll('[role="menuitem"]')].find( (item) => item.textContent === "Try again", )!; expect(retry).toBeDefined(); await act(async () => retry.focus()); await press("Enter"); expect(onRetryMachines).toHaveBeenCalledTimes(1); }); test("in the + menu, Visibility rows are menu radios with the value focused", async () => { const onChange = mock(); await renderInMenu(); expect(document.querySelectorAll('[role="radio"]')).toHaveLength(0); expect(row("Only me").getAttribute("aria-checked")).toBe("true"); expect(document.activeElement).toBe(row("Only me")); await press("ArrowUp"); expect(document.activeElement).toBe(row("Workspace")); await press("Enter"); expect(onChange).toHaveBeenCalledWith("workspace"); }); test("Send waiting on Runs on is explained under the composer", async () => { const onMachine = (sandboxId: string | null) => ({ ...emptySessionDraft(), compute: { kind: "machine" as const, sandboxId, folder: { kind: "root" as const } }, }); const base = { machines: [], fleetLoadFailed: false, fleetLoading: false }; expect(runsOnAttention({ ...base, draft: emptySessionDraft() })).toBeNull(); expect(runsOnAttention({ ...base, draft: onMachine(null) })).toBe("connect-machine"); expect(runsOnAttention({ ...base, draft: onMachine(null), fleetLoading: true })).toBeNull(); expect( runsOnAttention({ ...base, draft: onMachine(null), machines: [machine("build-01")] }), ).toBe("pick-machine"); expect( runsOnAttention({ ...base, draft: onMachine("sandbox-build-01"), machines: [machine("build-01")], }), ).toBeNull(); expect(runsOnAttention({ ...base, draft: emptySessionDraft(), fleetLoadFailed: true })).toBe( "fleet-load-failed", ); const onRetryMachines = mock(); await act(async () => root.render( , ), ); expect(container.textContent).toContain("Couldn't load your connected machines."); const retry = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Retry", )!; await act(async () => retry.click()); expect(onRetryMachines).toHaveBeenCalledTimes(1); await act(async () => root.render( {}} />, ), ); expect(container.textContent).toContain("Pick a machine under + > Runs on to send."); await act(async () => root.render( {}} />, ), ); expect(container.textContent).toContain("offline"); expect(container.textContent).toContain("+ > Runs on"); await act(async () => root.render( {}} connectAction={Connect a machine} />, ), ); expect(container.textContent).toContain("Connect one to send."); expect(container.querySelector("a")?.textContent).toBe("Connect a machine"); }); test("a removed or offline machine stays visible and requires replacement", async () => { const draft = { ...emptySessionDraft(), compute: { kind: "machine" as const, sandboxId: "removed", folder: { kind: "root" as const } }, }; const state = choices({ draft, machines: [], rigs: [] }); expect(hasRunsOnChoices(state)).toBe(true); expect(runsOnSummary(state)).toBe("Machine unavailable"); expect(runsOnAttention({ ...state, fleetLoading: false })).toBe("machine-unavailable"); await act(async () => root.render( {}} />, ), ); expect(container.textContent).toContain("Machine unavailable"); expect(container.textContent).toContain("choose another"); });