import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { Signal } from "@char/aftercare"; import { until } from "@berry/util"; import { fill, press } from "@berry/util/dom.ts"; import type { ProjectIndex } from "@berry/api/projects.ts"; import { type Page, withBerry } from "./testing.ts"; const projects: ProjectIndex = { berry: { name: "Berry", defaultRoot: 1, roots: [ { environment: "local", cwd: "/work/berry", platform: "linux" }, { environment: "remote", cwd: "/workspaces/berry", platform: "linux" }, ], }, other: { name: "Other", defaultRoot: 0, roots: [{ environment: "local", cwd: "/work/other", platform: "linux" }], }, }; const environment = { name: "environment", description: null, status: "loaded", error: null, running: true, builtin: true, alwaysOn: true, defaultEnabled: false, web: null, formerly: [], configuration: { properties: { environment: { type: "string", default: "local" }, cwd: { type: "string", default: "" }, }, }, }; const withPage = (test: (page: Page) => Promise) => withBerry(test, { globals: { confirm: () => true } }); Deno.test("folder autocomplete keeps the input focused, selects by keyboard, and ignores stale machine responses", async () => { await withPage(async ({ scope, server }) => { const { folderInput } = await import("./folders.tsx"); const machine = new Signal("local"); const pending: ((paths: string[]) => void)[] = []; const signals: (AbortSignal | undefined)[] = []; server.respond((_, signal) => { signals.push(signal); return new Promise((resolve) => pending.push(resolve)); }); const field = folderInput({ scope, environment: machine, base: () => "/base", projects: new Signal(projects), }); document.body.append(field.element); field.input.focus(); fill(field.input, "be"); assertStringIncludes(field.element.textContent!, "Berry"); await until(() => pending.length === 1); machine.set("remote"); await until(() => pending.length === 2); assertEquals( new URL(server.requests[1].path, "http://berry.test").searchParams.get("environment"), "remote", ); assertEquals( new URL(server.requests[1].path, "http://berry.test").searchParams.get("cwd"), "/base", ); assertEquals(signals[0]?.aborted, true); pending[1](["beta/", "berry workspace/"]); await until(() => field.element.textContent!.includes("beta/")); pending[0](["wrong-machine/"]); await new Promise((resolve) => setTimeout(resolve, 0)); assertEquals(field.element.textContent!.includes("wrong-machine"), false); press(field.input, "ArrowUp"); press(field.input, "Enter"); assertEquals(field.input.value, "berry workspace/"); assertEquals(document.activeElement, field.input); assertEquals(field.input.getAttribute("aria-expanded"), "false"); fill(field.input, "berry"); press(field.input, "Escape"); assertEquals(field.input.getAttribute("aria-expanded"), "false"); assertEquals(field.input.value, "berry"); }); }); Deno.test("environment configuration completes the selected machine and commits a machine switch with its reset folder", async () => { await withPage(async ({ scope, server }) => { const { configurationEditor } = await import("./configuration.tsx"); const saves: Promise[] = []; server.respond(({ method }) => method === "PATCH" ? { environment: "remote", cwd: "/remote-home" } : ["next/"] ); const editor = configurationEditor( scope, "aaaaaaaaaaaaa", "environment", { properties: { environment: { type: "string" }, cwd: { type: "string" }, }, }, (save) => saves.push(save), { environment: new Signal(["local", "remote"]) }, new Signal(projects), ); const form = editor.element; document.body.append(form); server.snapshot("configuration:aaaaaaaaaaaaa:environment", { environment: "local", cwd: "/work/berry", }); const input = form.querySelector("input")!; input.focus(); fill(input, "n"); await until(() => form.textContent!.includes("next/")); const query = new URL(server.requests[0].path, "http://berry.test").searchParams; assertEquals(query.get("cwd"), "/work/berry"); press(input, "ArrowDown"); press(input, "Enter"); assertEquals(input.value, "next/"); assertEquals(saves.length, 0); server.snapshot("configuration:aaaaaaaaaaaaa:environment", { environment: "local", cwd: "next/", }); input.blur(); await Promise.all(saves); const select = form.querySelector("select")!; select.focus(); select.value = "remote"; select.dispatchEvent(new Event("change", { bubbles: true })); assertEquals(input.value, ""); select.blur(); assertEquals(saves.length, 0); await editor.commit(); await Promise.all(saves); assertEquals(server.requests.at(-1)?.body, { environment: "remote", cwd: "" }); assertEquals(input.value, "/remote-home"); }); }); Deno.test("a list configuration keeps answers no longer offered and stages its boxes until committed", async () => { await withPage(async ({ scope, server }) => { const { configurationEditor } = await import("./configuration.tsx"); const saves: Promise[] = []; server.respond(({ body }) => body); const editor = configurationEditor( scope, "aaaaaaaaaaaaa", "subagent", { properties: { models: { type: "array", items: { type: "string" }, default: [] } } }, (save) => saves.push(save), { models: new Signal(["faux/a", "faux/b"]) }, ); const form = editor.element; document.body.append(form); server.snapshot("configuration:aaaaaaaaaaaaa:subagent", { models: ["faux/gone"] }); const boxes = () => [...form.querySelectorAll("input[type=checkbox]")]; assertEquals(boxes().map((box) => [box.value, box.checked]), [ ["faux/a", false], ["faux/b", false], ["faux/gone", true], ]); const [a, b] = boxes(); a.focus(); a.click(); b.focus(); assertEquals(saves.length, 0); b.blur(); assertEquals(saves.length, 0); await editor.commit(); await Promise.all(saves); assertEquals(server.requests.at(-1)?.method, "PATCH"); assertEquals(server.requests.at(-1)?.body, { models: ["faux/a", "faux/gone"] }); }); }); Deno.test("project presets use their default workspace and preserve an explicitly chosen workspace on refresh", async () => { await withPage(async ({ scope }) => { const { projectPicker } = await import("./projects.tsx"); const index = new Signal(projects); const picked: unknown[] = []; const picker = projectPicker(scope, index, (root) => picked.push(root)); document.body.append(picker.element); picker.pick("berry"); assertEquals(picked.at(-1), projects.berry.roots[1]); const workspace = document.querySelector('select[aria-label="workspace"]')!; workspace.value = "0"; workspace.dispatchEvent(new Event("change")); assertEquals(picked.at(-1), projects.berry.roots[0]); index.set({ ...projects, berry: { ...projects.berry, name: "Renamed" } }); assertEquals(workspace.value, "0"); picker.pick(""); assertEquals(picked.at(-1), null); }); }); Deno.test("sidebar groups independent workspaces, retains misc, and separates cross-project forks without losing ancestry", async () => { await withPage(async ({ scope, server }) => { const { app } = await import("./app.tsx"); document.body.append(app(scope)); server.snapshot("settings", { models: [], environments: { remote: { ssh: "remote" } } }); server.snapshot("extensions", [environment]); const parent = "aaaaaaaaaaaaa", same = "bbbbbbbbbbbbb", cross = "ccccccccccccc", misc = "ddddddddddddd"; const info = { id: 1, created: 1 }; server.snapshot("conversations", { [parent]: info, [same]: { ...info, id: 2, parent: { tid: parent, at: 1 } }, [cross]: { ...info, id: 3, parent: { tid: parent, at: 1 } }, [misc]: { ...info, id: 4 }, }); const summary = { busy: false, preview: "hello", unread: false, updated: 1 }; server.snapshot("summaries", { [parent]: { ...summary, environment: "local", cwd: "/work/berry/src" }, [same]: { ...summary, environment: "remote", cwd: "/workspaces/berry" }, [cross]: { ...summary, environment: "local", cwd: "/work/other" }, [misc]: { ...summary, environment: "local", cwd: "/misc" }, }); server.snapshot("projects", projects); const link = (tid: string) => document.querySelector(`.item > a:not(.parent-link)[href="#/${tid}"]`); assert(link(parent)); assert(link(cross)); assert(link(misc)); assertEquals(link(same), null); link(parent)!.parentElement!.querySelector(".chevron")!.click(); assert(link(same)); assertEquals( link(cross)!.parentElement!.querySelector(".parent-link") ?.getAttribute("href"), `#/${parent}`, ); const berryGroup = document.querySelector('[data-project="berry"]')!; berryGroup.querySelector(".project-toggle")!.click(); assertEquals(link(parent), null); assertEquals(link(same), null); assert(link(cross)); assert(link(misc)); server.snapshot("projects", { other: projects.other }); assert(link(parent)); assert(link(same)); server.snapshot("projects", projects); document.querySelector('[aria-label="New conversation in Berry"]')!.click(); await until(() => document.querySelector(".start-environment input") !== null); assertEquals(location.hash, "#/new/berry"); assertEquals( document.querySelector(".start-environment select")!.value, "remote", ); assertEquals( document.querySelector(".start-environment input")!.value, "/workspaces/berry", ); }); }); Deno.test("sidebar groups, no project included, keep their saved order and move by dragging or the editor's arrows", async () => { await withPage(async ({ scope, server }) => { const { app } = await import("./app.tsx"); document.body.append(app(scope)); server.snapshot("projects", projects); server.snapshot("project-order", { ids: ["gone", "other"] }); server.snapshot("conversations", { aaaaaaaaaaaaa: { id: 1, created: 1 } }); const groups = () => [...document.querySelectorAll("[data-project]")].map((node) => node.dataset.project ); assertEquals(groups(), ["other", "berry", ""]); const drag = (type: string, id: string) => document.querySelector(`[data-project="${id}"] .project-toggle`)! .dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); drag("dragstart", ""); drag("dragover", "other"); drag("drop", "other"); drag("dragend", ""); await until(() => groups()[0] === ""); assertEquals(server.changes().at(-1)?.body, { ids: ["", "other", "berry"] }); document.querySelector('[aria-label="Edit no project"]')!.click(); const up = document.querySelector('[aria-label="move up"]')!; const down = document.querySelector('[aria-label="move down"]')!; assert(up.disabled); down.click(); await until(() => groups()[1] === ""); assertEquals(server.changes().at(-1)?.body, { ids: ["other", "", "berry"] }); assert(!up.disabled); }); }); Deno.test("a folder typed for a new conversation shows the project and workspace it is in", async () => { await withPage(async ({ scope, server }) => { const { app } = await import("./app.tsx"); location.hash = "#/new"; document.body.append(app(scope)); server.snapshot("settings", { models: [], environments: { remote: { ssh: "remote" } } }); server.snapshot("extensions", [environment]); server.snapshot("projects", projects); await until(() => document.querySelector(".start-environment input") !== null); const folder = document.querySelector(".start-environment input")!; const project = document.querySelector('select[aria-label="project"]')!; const workspace = document.querySelector('select[aria-label="workspace"]')!; fill(folder, "/work/berry/src"); assertEquals([project.value, workspace.value], ["berry", "0"]); fill(folder, "/elsewhere"); assertEquals(project.value, ""); }); }); Deno.test("project editor saves multiple workspaces and a default without modifying conversations", async () => { await withPage(async ({ scope, server }) => { const { projectEditor } = await import("./projects.tsx"); server.respond(() => ({ id: "saved" })); const editor = projectEditor(scope, new Signal(["local", "remote"]), new Signal(projects)); document.body.append(editor.element); editor.open("berry"); fill(editor.element.querySelector("input[maxlength]")!, "Renamed"); editor.element.querySelector('button[type="submit"]')!.click(); await until(() => !editor.element.open); assertEquals(server.requests.at(-1), { path: "/api/projects/berry", method: "PUT", body: { name: "Renamed", defaultRoot: 1, roots: projects.berry.roots.map(({ environment, cwd }) => ({ environment, cwd })), }, }); editor.open("berry"); const remove = [...editor.element.querySelectorAll("button")].find(( button, ) => button.textContent === "delete project")!; remove.click(); await until(() => !editor.element.open); assertEquals(server.requests.at(-1)?.method, "DELETE"); assertEquals( server.requests.filter((request) => request.path.includes("/conversations")).length, 0, ); editor.open(); fill(editor.element.querySelector("input[maxlength]")!, "New project"); fill(editor.element.querySelector("input[role=combobox]")!, "/new/main"); [...editor.element.querySelectorAll("button")].find((button) => button.textContent === "add workspace" )!.click(); const second = editor.element.querySelectorAll(".project-root")[1]; const machine = second.querySelector("select")!; machine.value = "remote"; machine.dispatchEvent(new Event("change")); fill(second.querySelector("input[role=combobox]")!, "/new/feature"); second.querySelector('input[type="radio"]')!.click(); editor.element.querySelector('button[type="submit"]')!.click(); await until(() => !editor.element.open); assertEquals(server.requests.at(-1)?.body, { name: "New project", defaultRoot: 1, roots: [ { environment: "local", cwd: "/new/main" }, { environment: "remote", cwd: "/new/feature" }, ], }); assertEquals(server.requests.at(-1)?.method, "POST"); }); }); Deno.test("workspace removal offers an opt-in move on save and resets it when reopening", async () => { await withPage(async ({ scope, server }) => { const { projectEditor } = await import("./projects.tsx"); server.respond(() => ({ id: "saved" })); const editor = projectEditor(scope, new Signal(["local", "remote"]), new Signal(projects)); document.body.append(editor.element); editor.open("berry"); const move = editor.element.querySelector('input[type="checkbox"]')!; assert(move.closest("label")!.hidden); editor.element.querySelector('[aria-label="remove workspace 2"]')!.click(); assert(!move.closest("label")!.hidden); assert(!move.checked); assertEquals(server.requests.length, 0); move.click(); editor.element.querySelector('button[type="submit"]')!.click(); await until(() => !editor.element.open); assertEquals(server.requests.at(-1)?.body, { name: "Berry", roots: [{ environment: "local", cwd: "/work/berry" }], defaultRoot: 0, moveConversations: true, }); editor.open("berry"); assert(!move.checked); editor.element.querySelector('[aria-label="remove workspace 1"]')!.click(); editor.element.querySelector('button[type="submit"]')!.click(); await until(() => !editor.element.open); assertEquals(server.requests.at(-1)?.body, { name: "Berry", roots: [{ environment: "remote", cwd: "/workspaces/berry" }], defaultRoot: 0, }); editor.open("berry"); editor.element.querySelector('[aria-label="remove workspace 2"]')!.click(); move.click(); [...editor.element.querySelectorAll("button")].find((button) => button.textContent === "cancel" )!.click(); assertEquals(server.requests.length, 2); editor.open("berry"); assert(!move.checked); assert(move.closest("label")!.hidden); }); });