@webappwiz/arbor 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +34 -12
  3. package/add.test.ts +125 -0
  4. package/add.ts +192 -0
  5. package/age.ts +11 -0
  6. package/arbor.test.ts +145 -0
  7. package/arbor.ts +429 -0
  8. package/assets.d.ts +9 -0
  9. package/attachments.ts +87 -0
  10. package/build.ts +98 -0
  11. package/claim.test.ts +89 -0
  12. package/claim.ts +73 -0
  13. package/components.json +21 -0
  14. package/config.ts +58 -0
  15. package/dev/api.ts +120 -0
  16. package/dev/app.test.tsx +645 -0
  17. package/dev/app.tsx +80 -0
  18. package/dev/assets.ts +16 -0
  19. package/dev/build/main.txt +68 -0
  20. package/dev/build/shell.txt +21 -0
  21. package/dev/build/styles.txt +2263 -0
  22. package/dev/components/ui/badge.tsx +51 -0
  23. package/dev/components/ui/button.tsx +57 -0
  24. package/dev/components/ui/dialog.tsx +154 -0
  25. package/dev/components/ui/empty.tsx +100 -0
  26. package/dev/components/ui/input-group.tsx +155 -0
  27. package/dev/components/ui/input.tsx +19 -0
  28. package/dev/components/ui/progress.tsx +80 -0
  29. package/dev/components/ui/textarea.tsx +17 -0
  30. package/dev/components/ui/toast.tsx +227 -0
  31. package/dev/feed.ts +69 -0
  32. package/dev/files.tsx +191 -0
  33. package/dev/index.html +21 -0
  34. package/dev/lib/utils.ts +7 -0
  35. package/dev/main.tsx +22 -0
  36. package/dev/markdown.test.tsx +133 -0
  37. package/dev/markdown.tsx +124 -0
  38. package/dev/mentions.tsx +319 -0
  39. package/dev/shadcn.css +649 -0
  40. package/dev/styles.css +107 -0
  41. package/dev/tags.tsx +44 -0
  42. package/dev/tasks.tsx +151 -0
  43. package/dev/todos.tsx +759 -0
  44. package/dev.test.ts +439 -0
  45. package/dev.ts +386 -0
  46. package/e2e.test.ts +256 -0
  47. package/escalate.test.ts +76 -0
  48. package/escalate.ts +112 -0
  49. package/exit.test.ts +71 -0
  50. package/exit.ts +74 -0
  51. package/git.ts +294 -0
  52. package/index.ts +9 -0
  53. package/journal.ts +89 -0
  54. package/list.test.ts +82 -0
  55. package/list.ts +137 -0
  56. package/load-config.test.ts +121 -0
  57. package/load-config.ts +62 -0
  58. package/log.test.ts +65 -0
  59. package/log.ts +40 -0
  60. package/merge.test.ts +332 -0
  61. package/merge.ts +283 -0
  62. package/package.json +32 -15
  63. package/path.test.ts +35 -0
  64. package/path.ts +38 -0
  65. package/plan.test.ts +318 -0
  66. package/plan.ts +302 -0
  67. package/progress.test.ts +54 -0
  68. package/progress.ts +31 -0
  69. package/remove.test.ts +68 -0
  70. package/remove.ts +88 -0
  71. package/repository.ts +83 -0
  72. package/retry.test.ts +43 -0
  73. package/retry.ts +48 -0
  74. package/shell.ts +44 -0
  75. package/show.test.ts +91 -0
  76. package/show.ts +151 -0
  77. package/snapshot.test.ts +33 -0
  78. package/snapshot.ts +84 -0
  79. package/table.ts +20 -0
  80. package/testing.ts +229 -0
  81. package/todo.test.ts +490 -0
  82. package/todo.ts +834 -0
  83. package/wait.test.ts +61 -0
  84. package/wait.ts +77 -0
  85. package/worktree-service.test.ts +158 -0
  86. package/worktree-service.ts +193 -0
  87. package/worktree.ts +239 -0
  88. package/add.d.ts +0 -23
  89. package/age.d.ts +0 -2
  90. package/arbor.d.ts +0 -11
  91. package/attachments.d.ts +0 -36
  92. package/claim.d.ts +0 -11
  93. package/config.d.ts +0 -55
  94. package/config.js +0 -7
  95. package/dev/assets.d.ts +0 -11
  96. package/dev.d.ts +0 -47
  97. package/escalate.d.ts +0 -28
  98. package/exit.d.ts +0 -42
  99. package/git.d.ts +0 -77
  100. package/index.d.ts +0 -2
  101. package/index.js +0 -4669
  102. package/journal.d.ts +0 -29
  103. package/list.d.ts +0 -18
  104. package/load-config.d.ts +0 -15
  105. package/log.d.ts +0 -14
  106. package/merge.d.ts +0 -24
  107. package/path.d.ts +0 -13
  108. package/plan.d.ts +0 -83
  109. package/remove.d.ts +0 -19
  110. package/repository.d.ts +0 -25
  111. package/retry.d.ts +0 -17
  112. package/shell.d.ts +0 -17
  113. package/show.d.ts +0 -53
  114. package/snapshot.d.ts +0 -31
  115. package/table.d.ts +0 -5
  116. package/todo.d.ts +0 -230
  117. package/wait.d.ts +0 -22
  118. package/worktree-service.d.ts +0 -51
  119. package/worktree.d.ts +0 -95
@@ -0,0 +1,645 @@
1
+ // Imported for the side effect, and before anything that touches a global:
2
+ // it is what puts a DOM on `globalThis` for React to render into.
3
+ import "../../../setup";
4
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
5
+ import type { Details } from "../show";
6
+ import type { Snapshot } from "../snapshot";
7
+ import type { TodoState } from "../todo";
8
+
9
+ // Loaded only once the DOM above is in place. Static imports are evaluated
10
+ // before this file's own `setup` import has registered it, and react-dom
11
+ // decides at load time whether the browser has `input` events: loaded too
12
+ // early, it concludes it does not, and no `onChange` ever fires.
13
+ const { act, cleanup, fireEvent, render, waitFor, within } = await import(
14
+ "@testing-library/react"
15
+ );
16
+ const { App } = await import("./app");
17
+
18
+ /**
19
+ * The page never touches arbor: it reads a `Snapshot` the server already
20
+ * assembled and writes todos. So the fake is the snapshot and a record
21
+ * of the posts, and none of these tests need a repo, a worktree or the CLI.
22
+ * `dev.test.ts` covers the half that does.
23
+ */
24
+ let served: Snapshot;
25
+ /** Set to fail the next fetch, standing in for a server that went away. */
26
+ let down: boolean;
27
+ /** Every write the page made, in order. */
28
+ let posts: {
29
+ path: string;
30
+ method: string;
31
+ body: BodyInit | null | undefined;
32
+ }[];
33
+ /** What `/api/paths` lists, for `@`. */
34
+ let tree: string[];
35
+ /** The stream the page opened, so a test can push through it. */
36
+ let stream: FakeEventSource | null;
37
+
38
+ /** As much of `EventSource` as `Feed` uses: three handlers and a close. */
39
+ class FakeEventSource {
40
+ onmessage: (() => void) | null = null;
41
+ onopen: (() => void) | null = null;
42
+ onerror: (() => void) | null = null;
43
+
44
+ constructor() {
45
+ stream = this;
46
+ }
47
+
48
+ close(): void {}
49
+ }
50
+
51
+ const realFetch = globalThis.fetch;
52
+ const realEventSource = globalThis.EventSource;
53
+
54
+ function snapshot(overrides: Partial<Snapshot> = {}): Snapshot {
55
+ return {
56
+ repo: "webappwiz",
57
+ path: "~/Projects/webappwiz",
58
+ todoStalenessMs: 30 * 24 * 60 * 60 * 1000,
59
+ todos: [],
60
+ tasks: [],
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ beforeEach(() => {
66
+ served = snapshot();
67
+ tree = [];
68
+ down = false;
69
+ posts = [];
70
+ stream = null;
71
+ globalThis.fetch = (async (path: string, init?: RequestInit) => {
72
+ if (down) {
73
+ throw new Error("no server");
74
+ }
75
+ if (init?.method !== undefined && init.method !== "GET") {
76
+ posts.push({ path, method: init.method, body: init.body });
77
+ return Response.json({});
78
+ }
79
+ if (path.startsWith("/api/paths")) {
80
+ return Response.json(tree);
81
+ }
82
+ return Response.json(served);
83
+ }) as unknown as typeof fetch;
84
+ globalThis.EventSource = FakeEventSource as unknown as typeof EventSource;
85
+ });
86
+
87
+ afterEach(() => {
88
+ cleanup();
89
+ globalThis.fetch = realFetch;
90
+ globalThis.EventSource = realEventSource;
91
+ });
92
+
93
+ function details(overrides: Partial<Details> = {}): Details {
94
+ return {
95
+ task: "alpha",
96
+ status: "working",
97
+ branch: "task/alpha",
98
+ base: "main",
99
+ worktree: "/tmp/repo-arbor/alpha",
100
+ lease: "none",
101
+ ahead: 1,
102
+ added: 2,
103
+ removed: 1,
104
+ age: "3m",
105
+ escalation: null,
106
+ review: null,
107
+ plan: null,
108
+ planProblems: [],
109
+ ...overrides,
110
+ };
111
+ }
112
+
113
+ function todo(overrides: Partial<TodoState> = {}): TodoState {
114
+ return {
115
+ id: 1,
116
+ subject: "retry the upload",
117
+ text: "",
118
+ position: 1,
119
+ from: null,
120
+ createdAt: new Date().toISOString(),
121
+ takenBy: null,
122
+ files: [],
123
+ tags: [],
124
+ ...overrides,
125
+ };
126
+ }
127
+
128
+ /** Renders the page with a snapshot already waiting, and lets it arrive. */
129
+ async function open(overrides: Partial<Snapshot> = {}) {
130
+ served = snapshot(overrides);
131
+ const view = render(<App />);
132
+ await waitFor(() =>
133
+ expect(view.getByRole("region", { name: "Todos" })).toBeTruthy(),
134
+ );
135
+ return view;
136
+ }
137
+
138
+ describe("header", () => {
139
+ it("has no status line, and opens on the todos", async () => {
140
+ const view = await open();
141
+
142
+ expect(view.queryByLabelText("banner")).toBeNull();
143
+ expect(view.getByRole("textbox", { name: "new todo" })).toBeTruthy();
144
+ expect(view.queryByRole("tab", { name: /blocked/i })).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe("repo", () => {
149
+ it("names the repo, and shows where it sits", async () => {
150
+ const view = await open();
151
+
152
+ expect(view.getByRole("heading", { level: 1 }).textContent).toBe(
153
+ "webappwiz",
154
+ );
155
+ expect(view.getByText("~/Projects/webappwiz")).toBeTruthy();
156
+ });
157
+ });
158
+
159
+ describe("@ files", () => {
160
+ /** The new todo's box, with the tree's paths loaded. */
161
+ async function todoBox(paths: string[]) {
162
+ tree = paths;
163
+ const view = await open();
164
+ const box = view.getByRole("textbox", {
165
+ name: "new todo",
166
+ }) as HTMLInputElement;
167
+ return { view, box };
168
+ }
169
+
170
+ /** Types `value` as if the caret ended up at its end. */
171
+ async function type(box: HTMLInputElement, value: string) {
172
+ await act(async () => {
173
+ box.focus();
174
+ fireEvent.change(box, { target: { value } });
175
+ box.setSelectionRange(value.length, value.length);
176
+ fireEvent.select(box);
177
+ });
178
+ }
179
+
180
+ it("offers the repo's files for an @, best match first, and writes the one picked", async () => {
181
+ const { view, box } = await todoBox([
182
+ "src/",
183
+ "src/app.tsx",
184
+ "src/lib/",
185
+ "src/lib/apply.ts",
186
+ "README.md",
187
+ ]);
188
+
189
+ await type(box, "see @app");
190
+
191
+ const options = await waitFor(() => view.getAllByRole("option"));
192
+ expect(options.map((option) => option.textContent)).toEqual([
193
+ "app.tsxsrc/",
194
+ "apply.tssrc/lib/",
195
+ ]);
196
+ await act(async () => fireEvent.keyDown(box, { key: "ArrowDown" }));
197
+ await act(async () => fireEvent.keyDown(box, { key: "Enter" }));
198
+
199
+ expect(box.value).toBe("see @src/lib/apply.ts ");
200
+ expect(view.queryByRole("listbox")).toBeNull();
201
+ // Enter picked a file; it did not add the todo.
202
+ expect(posts).toEqual([]);
203
+ });
204
+
205
+ it("keeps the list open inside a directory picked", async () => {
206
+ const { view, box } = await todoBox(["src/", "src/lib/", "src/lib/a.ts"]);
207
+
208
+ await type(box, "@sr");
209
+ await act(async () =>
210
+ fireEvent.mouseDown(view.getByRole("option", { name: /^src\// })),
211
+ );
212
+ expect(box.value).toBe("@src/");
213
+ await type(box, box.value);
214
+
215
+ expect(
216
+ view.getAllByRole("option").map((option) => option.textContent),
217
+ ).toEqual(["lib/src/", "a.tssrc/lib/"]);
218
+ });
219
+
220
+ it("closes the list on Escape, leaving the text as typed", async () => {
221
+ const { view, box } = await todoBox(["src/app.tsx"]);
222
+
223
+ await type(box, "@app");
224
+ await waitFor(() => view.getByRole("listbox"));
225
+ await act(async () => fireEvent.keyDown(box, { key: "Escape" }));
226
+
227
+ expect(view.queryByRole("listbox")).toBeNull();
228
+ expect(box.value).toBe("@app");
229
+ });
230
+
231
+ it("ignores an @ inside a word, like an address", async () => {
232
+ const { view, box } = await todoBox(["example.com"]);
233
+
234
+ await type(box, "mail me@example");
235
+
236
+ expect(view.queryByRole("listbox")).toBeNull();
237
+ });
238
+ });
239
+
240
+ describe("todos", () => {
241
+ it("lists todos as cards with a preview and badges, leaving out where they came from", async () => {
242
+ const view = await open({
243
+ todos: [
244
+ todo({
245
+ id: 1,
246
+ subject: "fresh",
247
+ from: "alpha",
248
+ text: "more to say",
249
+ files: ["/tmp/a.png", "/tmp/b.log"],
250
+ }),
251
+ todo({
252
+ id: 2,
253
+ subject: "ancient",
254
+ position: 2,
255
+ createdAt: "2020-01-01T00:00:00Z",
256
+ }),
257
+ todo({ id: 3, subject: "taken", position: 3, takenBy: "beta" }),
258
+ ],
259
+ });
260
+
261
+ expect(document.body.textContent).not.toContain("alpha");
262
+ const cards = within(view.getByRole("list", { name: "todos" }))
263
+ .getAllByRole("listitem")
264
+ .map((card) => card.textContent);
265
+ expect(cards[0]).toContain("more to say");
266
+ expect(cards[0]).toContain("#1");
267
+ expect(
268
+ view.getByRole("img", { name: "files" }).parentElement?.textContent,
269
+ ).toBe("2");
270
+ expect(view.getAllByRole("img", { name: "stale" })).toHaveLength(1);
271
+ expect(view.getByRole("img", { name: "taken by" })).toBeTruthy();
272
+ expect(cards[2]).toContain("Taken by beta");
273
+ });
274
+
275
+ it("copies a link to one, without opening it", async () => {
276
+ const copied: string[] = [];
277
+ Object.defineProperty(navigator, "clipboard", {
278
+ configurable: true,
279
+ value: { writeText: async (text: string) => void copied.push(text) },
280
+ });
281
+ const view = await open({ todos: [todo({ id: 7, subject: "fix it" })] });
282
+
283
+ await act(async () =>
284
+ fireEvent.click(
285
+ view.getByRole("button", { name: "copy a link to todo 7" }),
286
+ ),
287
+ );
288
+
289
+ expect(copied).toEqual(["[ARBOR TODO #7]"]);
290
+ expect(view.queryByText("Todo 7")).toBeNull();
291
+ expect(view.getByText("Copied [ARBOR TODO #7]")).toBeTruthy();
292
+
293
+ await act(async () =>
294
+ fireEvent.click(view.getByRole("button", { name: "fix it" })),
295
+ );
296
+ // The toast is a dialog too.
297
+ const dialog = await waitFor(() =>
298
+ view.getByRole("dialog", { name: /Todo 7/ }),
299
+ );
300
+ await act(async () =>
301
+ fireEvent.click(
302
+ within(dialog).getByRole("button", { name: "copy a link to todo 7" }),
303
+ ),
304
+ );
305
+ expect(copied).toEqual(["[ARBOR TODO #7]", "[ARBOR TODO #7]"]);
306
+ });
307
+
308
+ it("opens the task that took one, without opening the todo", async () => {
309
+ const view = await open({
310
+ todos: [todo({ id: 3, subject: "taken", takenBy: "beta" })],
311
+ tasks: [
312
+ details({
313
+ task: "beta",
314
+ plan: "# beta\n\n## Goal\n\nship the thing\n",
315
+ }),
316
+ ],
317
+ });
318
+
319
+ await act(async () =>
320
+ fireEvent.click(view.getByRole("button", { name: /Taken by beta/ })),
321
+ );
322
+
323
+ const dialog = await waitFor(() => view.getByRole("dialog"));
324
+ expect(dialog.textContent).toContain("ship the thing");
325
+ expect(view.queryByText("Todo 3")).toBeNull();
326
+ });
327
+
328
+ it("adds one", async () => {
329
+ const view = await open();
330
+
331
+ const input = view.getByRole("textbox", { name: "new todo" });
332
+ await act(async () =>
333
+ fireEvent.change(input, { target: { value: "write the docs" } }),
334
+ );
335
+ await act(async () =>
336
+ fireEvent.change(view.getByRole("textbox", { name: "new todo detail" }), {
337
+ target: { value: "the CLI first" },
338
+ }),
339
+ );
340
+ await act(async () =>
341
+ fireEvent.click(view.getByRole("button", { name: "Add" })),
342
+ );
343
+
344
+ await waitFor(() => expect(posts).toHaveLength(1));
345
+ expect(posts[0]?.path).toBe("/api/todos");
346
+ const form = posts[0]?.body as FormData;
347
+ expect(form.get("subject")).toBe("write the docs");
348
+ expect(form.get("text")).toBe("the CLI first");
349
+ expect(form.has("position")).toBe(false);
350
+ expect(form.getAll("file")).toEqual([]);
351
+ });
352
+ });
353
+
354
+ describe("todo edits", () => {
355
+ it("renders a todo's detail as markdown, on its card and when opened", async () => {
356
+ const view = await open({
357
+ todos: [
358
+ todo({ id: 4, subject: "write docs", text: "start with **the CLI**" }),
359
+ ],
360
+ });
361
+
362
+ expect(view.getByText("the CLI").tagName).toBe("STRONG");
363
+ await act(async () => fireEvent.click(view.getByText("write docs")));
364
+ const dialog = await waitFor(() => view.getByRole("dialog"));
365
+ expect(within(dialog).getByText("the CLI").tagName).toBe("STRONG");
366
+ expect(dialog.textContent).toContain("Markdown supported");
367
+ });
368
+
369
+ it("rewords one", async () => {
370
+ const view = await open({
371
+ todos: [todo({ id: 4, subject: "write docs", text: "soon" })],
372
+ });
373
+
374
+ await act(async () => fireEvent.click(view.getByText("write docs")));
375
+ const box = await waitFor(() =>
376
+ view.getByRole("textbox", { name: "subject" }),
377
+ );
378
+ await act(async () =>
379
+ fireEvent.change(box, { target: { value: "write the docs" } }),
380
+ );
381
+ // A todo with detail opens on its preview.
382
+ expect(view.queryByRole("textbox", { name: "detail" })).toBeNull();
383
+ await act(async () =>
384
+ fireEvent.click(view.getByRole("button", { name: "Write" })),
385
+ );
386
+ await act(async () =>
387
+ fireEvent.change(view.getByRole("textbox", { name: "detail" }), {
388
+ target: { value: "" },
389
+ }),
390
+ );
391
+ await act(async () =>
392
+ fireEvent.click(view.getByRole("button", { name: "Save" })),
393
+ );
394
+
395
+ await waitFor(() => expect(posts).toHaveLength(1));
396
+ expect(posts[0]).toMatchObject({ path: "/api/todos/4", method: "PATCH" });
397
+ const form = posts[0]?.body as FormData;
398
+ expect(form.get("subject")).toBe("write the docs");
399
+ expect(form.get("text")).toBe("");
400
+ // Not moved, so no place is sent to undo a move made elsewhere.
401
+ expect(form.has("position")).toBe(false);
402
+ });
403
+
404
+ it("tags one from its dialog", async () => {
405
+ const view = await open({
406
+ todos: [todo({ id: 4, subject: "write docs", tags: ["docs"] })],
407
+ });
408
+
409
+ await act(async () => fireEvent.click(view.getByText("write docs")));
410
+ const box = await waitFor(() =>
411
+ view.getByRole("textbox", { name: "tags" }),
412
+ );
413
+ expect((box as HTMLInputElement).value).toBe("docs");
414
+ expect(box.getAttribute("aria-describedby")).toBeTruthy();
415
+ expect(view.getByText("Separate tags with commas.")).toBeTruthy();
416
+ await act(async () =>
417
+ fireEvent.change(box, { target: { value: "docs, dev-page" } }),
418
+ );
419
+ await act(async () =>
420
+ fireEvent.click(view.getByRole("button", { name: "Save" })),
421
+ );
422
+
423
+ await waitFor(() => expect(posts).toHaveLength(1));
424
+ const form = posts[0]?.body as FormData;
425
+ expect(form.get("tags")).toBe("docs,dev-page");
426
+ });
427
+
428
+ it("moves one up the list by its grip, from the keyboard", async () => {
429
+ // happy-dom lays nothing out, and a drag goes by where things are: stack
430
+ // the cards 50px apart so there is an above and a below.
431
+ const rect = Element.prototype.getBoundingClientRect;
432
+ Element.prototype.getBoundingClientRect = function (this: Element) {
433
+ const card = this.closest("li");
434
+ const at = card
435
+ ? [...(card.parentElement?.children ?? [])].indexOf(card)
436
+ : 0;
437
+ return DOMRect.fromRect({ x: 0, y: at * 50, width: 300, height: 40 });
438
+ };
439
+ try {
440
+ const view = await open({
441
+ todos: [
442
+ todo({ id: 1, subject: "first" }),
443
+ todo({ id: 4, subject: "second", position: 2 }),
444
+ ],
445
+ });
446
+
447
+ const grip = view.getByRole("button", { name: "move second" });
448
+ grip.focus();
449
+ for (const code of ["Space", "ArrowUp", "Space"]) {
450
+ await act(async () => {
451
+ fireEvent.keyDown(grip, { code });
452
+ await new Promise((settle) => setTimeout(settle, 20));
453
+ });
454
+ }
455
+
456
+ await waitFor(() => expect(posts).toHaveLength(1));
457
+ expect(posts[0]?.path).toBe("/api/todos/4/position");
458
+ expect(posts[0]?.method).toBe("PUT");
459
+ expect(JSON.parse(String(posts[0]?.body))).toEqual({ position: 1 });
460
+ // Where it was let go, before the server says so.
461
+ expect(
462
+ within(view.getByRole("list", { name: "todos" }))
463
+ .getAllByRole("listitem")
464
+ .map((card) => card.textContent),
465
+ ).toEqual([
466
+ expect.stringContaining("second"),
467
+ expect.stringContaining("first"),
468
+ ]);
469
+ } finally {
470
+ Element.prototype.getBoundingClientRect = rect;
471
+ }
472
+ });
473
+
474
+ it("drops a file attached to one", async () => {
475
+ const view = await open({
476
+ todos: [
477
+ todo({
478
+ id: 4,
479
+ subject: "fix the chart",
480
+ files: [
481
+ "/repo/.git/arbor/todos/4/0-a.png",
482
+ "/repo/.git/arbor/todos/4/1-b.log",
483
+ ],
484
+ }),
485
+ ],
486
+ });
487
+ expect(
488
+ view.getByRole("img", { name: "files" }).parentElement?.textContent,
489
+ ).toBe("2");
490
+
491
+ await act(async () => fireEvent.click(view.getByText("fix the chart")));
492
+ const save = await waitFor(() =>
493
+ view.getByRole("button", { name: "Save" }),
494
+ );
495
+ expect((save as HTMLButtonElement).disabled).toBe(true);
496
+ await act(async () =>
497
+ fireEvent.click(view.getByRole("button", { name: "remove b.log" })),
498
+ );
499
+ await act(async () => fireEvent.click(save));
500
+
501
+ await waitFor(() => expect(posts).toHaveLength(1));
502
+ expect((posts[0]?.body as FormData | undefined)?.getAll("keep")).toEqual([
503
+ "/repo/.git/arbor/todos/4/0-a.png",
504
+ ]);
505
+ });
506
+
507
+ it("removes one, asking twice", async () => {
508
+ const view = await open({
509
+ todos: [todo({ id: 4, subject: "write docs" })],
510
+ });
511
+
512
+ await act(async () => fireEvent.click(view.getByText("write docs")));
513
+ await act(async () =>
514
+ fireEvent.click(
515
+ await waitFor(() => view.getByRole("button", { name: "Remove" })),
516
+ ),
517
+ );
518
+ expect(posts).toHaveLength(0);
519
+ await act(async () =>
520
+ fireEvent.click(view.getByRole("button", { name: "Remove for good" })),
521
+ );
522
+
523
+ await waitFor(() => expect(posts).toHaveLength(1));
524
+ expect(posts[0]).toMatchObject({ path: "/api/todos/4", method: "DELETE" });
525
+ });
526
+ });
527
+
528
+ describe("tasks", () => {
529
+ it("flags only the statuses that are news, and opens a task's plan", async () => {
530
+ const view = await open({
531
+ tasks: [
532
+ details({ task: "alpha" }),
533
+ details({
534
+ task: "beta",
535
+ status: "escalated",
536
+ escalation: "needs eyes",
537
+ plan: "# beta\n\n## Goal\n\nship the thing\n",
538
+ }),
539
+ ],
540
+ });
541
+
542
+ expect(view.queryByText("working")).toBeNull();
543
+ expect(view.queryByRole("progressbar")).toBeNull();
544
+ expect(view.getByText("escalated")).toBeTruthy();
545
+
546
+ await act(async () =>
547
+ fireEvent.click(view.getByRole("button", { name: /beta/ })),
548
+ );
549
+
550
+ const dialog = await waitFor(() => view.getByRole("dialog"));
551
+ expect(dialog.textContent).toContain("ship the thing");
552
+ expect(dialog.textContent).toContain("needs eyes");
553
+ });
554
+
555
+ it("fills a bar with the steps its plan has checked off", async () => {
556
+ const view = await open({
557
+ tasks: [
558
+ details({
559
+ task: "alpha",
560
+ plan: "# alpha\n\n## Done\n\n- [x] one\n\n## Next\n\n- [ ] two\n- [ ] three\n- [ ] four\n",
561
+ }),
562
+ ],
563
+ });
564
+
565
+ const bar = view.getByRole("progressbar", { name: "progress" });
566
+ expect(bar.getAttribute("aria-valuenow")).toBe("1");
567
+ expect(bar.getAttribute("aria-valuemax")).toBe("4");
568
+ expect(view.getByText("1/4")).toBeTruthy();
569
+ });
570
+ });
571
+
572
+ describe("tags", () => {
573
+ it("filters the list by a tag from the row above it, or from a card", async () => {
574
+ const view = await open({
575
+ todos: [
576
+ todo({ id: 1, subject: "page bug", tags: ["page"] }),
577
+ todo({ id: 2, subject: "merge flake", position: 2, tags: ["merge"] }),
578
+ ],
579
+ });
580
+ const filter = view.getByRole("group", { name: "filter by tag" });
581
+ const list = view.getByRole("list", { name: "todos" });
582
+
583
+ expect(view.queryByRole("progressbar")).toBeNull();
584
+ await act(async () =>
585
+ fireEvent.click(within(filter).getByRole("button", { name: "page" })),
586
+ );
587
+ expect(within(list).queryByText("merge flake")).toBeNull();
588
+ expect(within(list).getByText("page bug")).toBeTruthy();
589
+
590
+ await act(async () =>
591
+ fireEvent.click(within(filter).getByRole("button", { name: "All" })),
592
+ );
593
+ expect(within(list).getByText("merge flake")).toBeTruthy();
594
+
595
+ await act(async () =>
596
+ fireEvent.click(view.getByRole("button", { name: "show only merge" })),
597
+ );
598
+ expect(within(list).queryByText("page bug")).toBeNull();
599
+ expect(
600
+ within(filter)
601
+ .getByRole("button", { name: "merge" })
602
+ .getAttribute("aria-pressed"),
603
+ ).toBe("true");
604
+ });
605
+ });
606
+
607
+ describe("page", () => {
608
+ it("shows the tasks and the todos together, with no tabs", async () => {
609
+ const view = await open({
610
+ tasks: [details({ task: "alpha" })],
611
+ todos: [todo({ subject: "later" })],
612
+ });
613
+
614
+ expect(view.queryByRole("tablist")).toBeNull();
615
+ expect(
616
+ within(view.getByRole("region", { name: "Tasks" })).getByText("alpha"),
617
+ ).toBeTruthy();
618
+ expect(
619
+ within(view.getByRole("region", { name: "Todos" })).getByText(/later/),
620
+ ).toBeTruthy();
621
+ });
622
+ });
623
+
624
+ describe("feed", () => {
625
+ it("refetches when the server says something moved", async () => {
626
+ const view = await open();
627
+
628
+ served = snapshot({
629
+ todos: [todo({ subject: "new one" })],
630
+ });
631
+ await act(async () => stream?.onmessage?.());
632
+
633
+ await waitFor(() => expect(view.getByText(/new one/)).toBeTruthy());
634
+ });
635
+
636
+ it("says it is offline when the server goes away", async () => {
637
+ const view = await open();
638
+ expect(view.queryByRole("status")).toBeNull();
639
+
640
+ down = true;
641
+ await act(async () => stream?.onmessage?.());
642
+
643
+ await waitFor(() => expect(view.getByText(/Offline/)).toBeTruthy());
644
+ });
645
+ });