char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow project reorderingb4fc6cf

main
17.0 KiB423 linesraw
1import { assert, assertEquals, assertStringIncludes } from "@std/assert";
2import { Signal } from "@char/aftercare";
3import { until } from "@berry/util";
4import { fill, press } from "@berry/util/dom.ts";
5import type { ProjectIndex } from "@berry/api/projects.ts";
6import { type Page, withBerry } from "./testing.ts";
7
8const projects: ProjectIndex = {
9  berry: {
10    name: "Berry",
11    defaultRoot: 1,
12    roots: [
13      { environment: "local", cwd: "/work/berry", platform: "linux" },
14      { environment: "remote", cwd: "/workspaces/berry", platform: "linux" },
15    ],
16  },
17  other: {
18    name: "Other",
19    defaultRoot: 0,
20    roots: [{ environment: "local", cwd: "/work/other", platform: "linux" }],
21  },
22};
23
24const environment = {
25  name: "environment",
26  description: null,
27  status: "loaded",
28  error: null,
29  running: true,
30  builtin: true,
31  alwaysOn: true,
32  defaultEnabled: false,
33  web: null,
34  formerly: [],
35  configuration: {
36    properties: {
37      environment: { type: "string", default: "local" },
38      cwd: { type: "string", default: "" },
39    },
40  },
41};
42
43const withPage = (test: (page: Page) => Promise<void>) =>
44  withBerry(test, { globals: { confirm: () => true } });
45
46Deno.test("folder autocomplete keeps the input focused, selects by keyboard, and ignores stale machine responses", async () => {
47  await withPage(async ({ scope, server }) => {
48    const { folderInput } = await import("./folders.tsx");
49    const machine = new Signal("local");
50    const pending: ((paths: string[]) => void)[] = [];
51    const signals: (AbortSignal | undefined)[] = [];
52    server.respond((_, signal) => {
53      signals.push(signal);
54      return new Promise((resolve) => pending.push(resolve));
55    });
56    const field = folderInput({
57      scope,
58      environment: machine,
59      base: () => "/base",
60      projects: new Signal(projects),
61    });
62    document.body.append(field.element);
63    field.input.focus();
64    fill(field.input, "be");
65    assertStringIncludes(field.element.textContent!, "Berry");
66    await until(() => pending.length === 1);
67    machine.set("remote");
68    await until(() => pending.length === 2);
69    assertEquals(
70      new URL(server.requests[1].path, "http://berry.test").searchParams.get("environment"),
71      "remote",
72    );
73    assertEquals(
74      new URL(server.requests[1].path, "http://berry.test").searchParams.get("cwd"),
75      "/base",
76    );
77    assertEquals(signals[0]?.aborted, true);
78    pending[1](["beta/", "berry workspace/"]);
79    await until(() => field.element.textContent!.includes("beta/"));
80    pending[0](["wrong-machine/"]);
81    await new Promise((resolve) => setTimeout(resolve, 0));
82    assertEquals(field.element.textContent!.includes("wrong-machine"), false);
83    press(field.input, "ArrowUp");
84    press(field.input, "Enter");
85    assertEquals(field.input.value, "berry workspace/");
86    assertEquals(document.activeElement, field.input);
87    assertEquals(field.input.getAttribute("aria-expanded"), "false");
88    fill(field.input, "berry");
89    press(field.input, "Escape");
90    assertEquals(field.input.getAttribute("aria-expanded"), "false");
91    assertEquals(field.input.value, "berry");
92  });
93});
94
95Deno.test("environment configuration completes the selected machine and commits a machine switch with its reset folder", async () => {
96  await withPage(async ({ scope, server }) => {
97    const { configurationEditor } = await import("./configuration.tsx");
98    const saves: Promise<unknown>[] = [];
99    server.respond(({ method }) =>
100      method === "PATCH" ? { environment: "remote", cwd: "/remote-home" } : ["next/"]
101    );
102    const editor = configurationEditor(
103      scope,
104      "aaaaaaaaaaaaa",
105      "environment",
106      {
107        properties: {
108          environment: { type: "string" },
109          cwd: { type: "string" },
110        },
111      },
112      (save) => saves.push(save),
113      { environment: new Signal(["local", "remote"]) },
114      new Signal(projects),
115    );
116    const form = editor.element;
117    document.body.append(form);
118    server.snapshot("configuration:aaaaaaaaaaaaa:environment", {
119      environment: "local",
120      cwd: "/work/berry",
121    });
122    const input = form.querySelector<HTMLInputElement>("input")!;
123    input.focus();
124    fill(input, "n");
125    await until(() => form.textContent!.includes("next/"));
126    const query = new URL(server.requests[0].path, "http://berry.test").searchParams;
127    assertEquals(query.get("cwd"), "/work/berry");
128    press(input, "ArrowDown");
129    press(input, "Enter");
130    assertEquals(input.value, "next/");
131    assertEquals(saves.length, 0);
132    server.snapshot("configuration:aaaaaaaaaaaaa:environment", {
133      environment: "local",
134      cwd: "next/",
135    });
136    input.blur();
137    await Promise.all(saves);
138    const select = form.querySelector<HTMLSelectElement>("select")!;
139    select.focus();
140    select.value = "remote";
141    select.dispatchEvent(new Event("change", { bubbles: true }));
142    assertEquals(input.value, "");
143    select.blur();
144    assertEquals(saves.length, 0);
145    await editor.commit();
146    await Promise.all(saves);
147    assertEquals(server.requests.at(-1)?.body, { environment: "remote", cwd: "" });
148    assertEquals(input.value, "/remote-home");
149  });
150});
151
152Deno.test("a list configuration keeps answers no longer offered and stages its boxes until committed", async () => {
153  await withPage(async ({ scope, server }) => {
154    const { configurationEditor } = await import("./configuration.tsx");
155    const saves: Promise<unknown>[] = [];
156    server.respond(({ body }) => body);
157    const editor = configurationEditor(
158      scope,
159      "aaaaaaaaaaaaa",
160      "subagent",
161      { properties: { models: { type: "array", items: { type: "string" }, default: [] } } },
162      (save) => saves.push(save),
163      { models: new Signal(["faux/a", "faux/b"]) },
164    );
165    const form = editor.element;
166    document.body.append(form);
167    server.snapshot("configuration:aaaaaaaaaaaaa:subagent", { models: ["faux/gone"] });
168    const boxes = () => [...form.querySelectorAll<HTMLInputElement>("input[type=checkbox]")];
169    assertEquals(boxes().map((box) => [box.value, box.checked]), [
170      ["faux/a", false],
171      ["faux/b", false],
172      ["faux/gone", true],
173    ]);
174    const [a, b] = boxes();
175    a.focus();
176    a.click();
177    b.focus();
178    assertEquals(saves.length, 0);
179    b.blur();
180    assertEquals(saves.length, 0);
181    await editor.commit();
182    await Promise.all(saves);
183    assertEquals(server.requests.at(-1)?.method, "PATCH");
184    assertEquals(server.requests.at(-1)?.body, { models: ["faux/a", "faux/gone"] });
185  });
186});
187
188Deno.test("project presets use their default workspace and preserve an explicitly chosen workspace on refresh", async () => {
189  await withPage(async ({ scope }) => {
190    const { projectPicker } = await import("./projects.tsx");
191    const index = new Signal(projects);
192    const picked: unknown[] = [];
193    const picker = projectPicker(scope, index, (root) => picked.push(root));
194    document.body.append(picker.element);
195    picker.pick("berry");
196    assertEquals(picked.at(-1), projects.berry.roots[1]);
197    const workspace = document.querySelector<HTMLSelectElement>('select[aria-label="workspace"]')!;
198    workspace.value = "0";
199    workspace.dispatchEvent(new Event("change"));
200    assertEquals(picked.at(-1), projects.berry.roots[0]);
201    index.set({ ...projects, berry: { ...projects.berry, name: "Renamed" } });
202    assertEquals(workspace.value, "0");
203    picker.pick("");
204    assertEquals(picked.at(-1), null);
205  });
206});
207
208Deno.test("sidebar groups independent workspaces, retains misc, and separates cross-project forks without losing ancestry", async () => {
209  await withPage(async ({ scope, server }) => {
210    const { app } = await import("./app.tsx");
211    document.body.append(app(scope));
212    server.snapshot("settings", { models: [], environments: { remote: { ssh: "remote" } } });
213    server.snapshot("extensions", [environment]);
214    const parent = "aaaaaaaaaaaaa",
215      same = "bbbbbbbbbbbbb",
216      cross = "ccccccccccccc",
217      misc = "ddddddddddddd";
218    const info = { id: 1, created: 1 };
219    server.snapshot("conversations", {
220      [parent]: info,
221      [same]: { ...info, id: 2, parent: { tid: parent, at: 1 } },
222      [cross]: { ...info, id: 3, parent: { tid: parent, at: 1 } },
223      [misc]: { ...info, id: 4 },
224    });
225    const summary = { busy: false, preview: "hello", unread: false, updated: 1 };
226    server.snapshot("summaries", {
227      [parent]: { ...summary, environment: "local", cwd: "/work/berry/src" },
228      [same]: { ...summary, environment: "remote", cwd: "/workspaces/berry" },
229      [cross]: { ...summary, environment: "local", cwd: "/work/other" },
230      [misc]: { ...summary, environment: "local", cwd: "/misc" },
231    });
232    server.snapshot("projects", projects);
233    const link = (tid: string) =>
234      document.querySelector<HTMLAnchorElement>(`.item > a:not(.parent-link)[href="#/${tid}"]`);
235    assert(link(parent));
236    assert(link(cross));
237    assert(link(misc));
238    assertEquals(link(same), null);
239    link(parent)!.parentElement!.querySelector<HTMLButtonElement>(".chevron")!.click();
240    assert(link(same));
241    assertEquals(
242      link(cross)!.parentElement!.querySelector<HTMLAnchorElement>(".parent-link")
243        ?.getAttribute("href"),
244      `#/${parent}`,
245    );
246    const berryGroup = document.querySelector<HTMLElement>('[data-project="berry"]')!;
247    berryGroup.querySelector<HTMLButtonElement>(".project-toggle")!.click();
248    assertEquals(link(parent), null);
249    assertEquals(link(same), null);
250    assert(link(cross));
251    assert(link(misc));
252    server.snapshot("projects", { other: projects.other });
253    assert(link(parent));
254    assert(link(same));
255    server.snapshot("projects", projects);
256    document.querySelector<HTMLButtonElement>('[aria-label="New conversation in Berry"]')!.click();
257    await until(() => document.querySelector(".start-environment input") !== null);
258    assertEquals(location.hash, "#/new/berry");
259    assertEquals(
260      document.querySelector<HTMLSelectElement>(".start-environment select")!.value,
261      "remote",
262    );
263    assertEquals(
264      document.querySelector<HTMLInputElement>(".start-environment input")!.value,
265      "/workspaces/berry",
266    );
267  });
268});
269
270Deno.test("sidebar groups, no project included, keep their saved order and move by dragging or the editor's arrows", async () => {
271  await withPage(async ({ scope, server }) => {
272    const { app } = await import("./app.tsx");
273    document.body.append(app(scope));
274    server.snapshot("projects", projects);
275    server.snapshot("project-order", { ids: ["gone", "other"] });
276    server.snapshot("conversations", { aaaaaaaaaaaaa: { id: 1, created: 1 } });
277    const groups = () =>
278      [...document.querySelectorAll<HTMLElement>("[data-project]")].map((node) =>
279        node.dataset.project
280      );
281    assertEquals(groups(), ["other", "berry", ""]);
282    const drag = (type: string, id: string) =>
283      document.querySelector(`[data-project="${id}"] .project-toggle`)!
284        .dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
285    drag("dragstart", "");
286    drag("dragover", "other");
287    drag("drop", "other");
288    drag("dragend", "");
289    await until(() => groups()[0] === "");
290    assertEquals(server.changes().at(-1)?.body, { ids: ["", "other", "berry"] });
291
292    document.querySelector<HTMLButtonElement>('[aria-label="Edit no project"]')!.click();
293    const up = document.querySelector<HTMLButtonElement>('[aria-label="move up"]')!;
294    const down = document.querySelector<HTMLButtonElement>('[aria-label="move down"]')!;
295    assert(up.disabled);
296    down.click();
297    await until(() => groups()[1] === "");
298    assertEquals(server.changes().at(-1)?.body, { ids: ["other", "", "berry"] });
299    assert(!up.disabled);
300  });
301});
302
303Deno.test("a folder typed for a new conversation shows the project and workspace it is in", async () => {
304  await withPage(async ({ scope, server }) => {
305    const { app } = await import("./app.tsx");
306    location.hash = "#/new";
307    document.body.append(app(scope));
308    server.snapshot("settings", { models: [], environments: { remote: { ssh: "remote" } } });
309    server.snapshot("extensions", [environment]);
310    server.snapshot("projects", projects);
311    await until(() => document.querySelector(".start-environment input") !== null);
312    const folder = document.querySelector<HTMLInputElement>(".start-environment input")!;
313    const project = document.querySelector<HTMLSelectElement>('select[aria-label="project"]')!;
314    const workspace = document.querySelector<HTMLSelectElement>('select[aria-label="workspace"]')!;
315    fill(folder, "/work/berry/src");
316    assertEquals([project.value, workspace.value], ["berry", "0"]);
317    fill(folder, "/elsewhere");
318    assertEquals(project.value, "");
319  });
320});
321
322Deno.test("project editor saves multiple workspaces and a default without modifying conversations", async () => {
323  await withPage(async ({ scope, server }) => {
324    const { projectEditor } = await import("./projects.tsx");
325    server.respond(() => ({ id: "saved" }));
326    const editor = projectEditor(scope, new Signal(["local", "remote"]), new Signal(projects));
327    document.body.append(editor.element);
328    editor.open("berry");
329    fill(editor.element.querySelector<HTMLInputElement>("input[maxlength]")!, "Renamed");
330    editor.element.querySelector<HTMLButtonElement>('button[type="submit"]')!.click();
331    await until(() => !editor.element.open);
332    assertEquals(server.requests.at(-1), {
333      path: "/api/projects/berry",
334      method: "PUT",
335      body: {
336        name: "Renamed",
337        defaultRoot: 1,
338        roots: projects.berry.roots.map(({ environment, cwd }) => ({ environment, cwd })),
339      },
340    });
341    editor.open("berry");
342    const remove = [...editor.element.querySelectorAll<HTMLButtonElement>("button")].find((
343      button,
344    ) => button.textContent === "delete project")!;
345    remove.click();
346    await until(() => !editor.element.open);
347    assertEquals(server.requests.at(-1)?.method, "DELETE");
348    assertEquals(
349      server.requests.filter((request) => request.path.includes("/conversations")).length,
350      0,
351    );
352    editor.open();
353    fill(editor.element.querySelector<HTMLInputElement>("input[maxlength]")!, "New project");
354    fill(editor.element.querySelector<HTMLInputElement>("input[role=combobox]")!, "/new/main");
355    [...editor.element.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
356      button.textContent === "add workspace"
357    )!.click();
358    const second = editor.element.querySelectorAll<HTMLFieldSetElement>(".project-root")[1];
359    const machine = second.querySelector<HTMLSelectElement>("select")!;
360    machine.value = "remote";
361    machine.dispatchEvent(new Event("change"));
362    fill(second.querySelector<HTMLInputElement>("input[role=combobox]")!, "/new/feature");
363    second.querySelector<HTMLInputElement>('input[type="radio"]')!.click();
364    editor.element.querySelector<HTMLButtonElement>('button[type="submit"]')!.click();
365    await until(() => !editor.element.open);
366    assertEquals(server.requests.at(-1)?.body, {
367      name: "New project",
368      defaultRoot: 1,
369      roots: [
370        { environment: "local", cwd: "/new/main" },
371        { environment: "remote", cwd: "/new/feature" },
372      ],
373    });
374    assertEquals(server.requests.at(-1)?.method, "POST");
375  });
376});
377
378Deno.test("workspace removal offers an opt-in move on save and resets it when reopening", async () => {
379  await withPage(async ({ scope, server }) => {
380    const { projectEditor } = await import("./projects.tsx");
381    server.respond(() => ({ id: "saved" }));
382    const editor = projectEditor(scope, new Signal(["local", "remote"]), new Signal(projects));
383    document.body.append(editor.element);
384    editor.open("berry");
385    const move = editor.element.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
386    assert(move.closest("label")!.hidden);
387    editor.element.querySelector<HTMLButtonElement>('[aria-label="remove workspace 2"]')!.click();
388    assert(!move.closest("label")!.hidden);
389    assert(!move.checked);
390    assertEquals(server.requests.length, 0);
391    move.click();
392    editor.element.querySelector<HTMLButtonElement>('button[type="submit"]')!.click();
393    await until(() => !editor.element.open);
394    assertEquals(server.requests.at(-1)?.body, {
395      name: "Berry",
396      roots: [{ environment: "local", cwd: "/work/berry" }],
397      defaultRoot: 0,
398      moveConversations: true,
399    });
400
401    editor.open("berry");
402    assert(!move.checked);
403    editor.element.querySelector<HTMLButtonElement>('[aria-label="remove workspace 1"]')!.click();
404    editor.element.querySelector<HTMLButtonElement>('button[type="submit"]')!.click();
405    await until(() => !editor.element.open);
406    assertEquals(server.requests.at(-1)?.body, {
407      name: "Berry",
408      roots: [{ environment: "remote", cwd: "/workspaces/berry" }],
409      defaultRoot: 0,
410    });
411
412    editor.open("berry");
413    editor.element.querySelector<HTMLButtonElement>('[aria-label="remove workspace 2"]')!.click();
414    move.click();
415    [...editor.element.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
416      button.textContent === "cancel"
417    )!.click();
418    assertEquals(server.requests.length, 2);
419    editor.open("berry");
420    assert(!move.checked);
421    assert(move.closest("label")!.hidden);
422  });
423});