@fungi.computer/bonsai 0.0.0-stage → 0.0.1

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 (132) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +835 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/read-copy-calls.test.ts +691 -0
  90. package/test/read-copy-composer.test.ts +388 -0
  91. package/test/read-copy-lifetimes.test.ts +214 -0
  92. package/test/read-copy-rpc.config.jsonc +12 -0
  93. package/test/read-copy-rpc.native.mjs +47 -0
  94. package/test/read-copy-rpc.worker.ts +92 -0
  95. package/test/recent-activity.test.ts +62 -0
  96. package/test/repository-deletion.test.ts +500 -0
  97. package/test/seed-resume.test.mjs +91 -0
  98. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  99. package/test/storyboard-save-template.test.mjs +545 -0
  100. package/test/ui-errors.test.ts +976 -0
  101. package/test/workflow-store.test.ts +115 -0
  102. package/tsconfig.json +36 -0
  103. package/turbo.jsonc +25 -0
  104. package/vite.config.ts +19 -0
  105. package/vitest.config.ts +5 -0
  106. package/web/api.ts +224 -0
  107. package/web/app-build-panel.tsx +171 -0
  108. package/web/board.tsx +259 -0
  109. package/web/bonsai.css +642 -0
  110. package/web/change-page.tsx +715 -0
  111. package/web/change-rows.tsx +135 -0
  112. package/web/code-tab.tsx +267 -0
  113. package/web/commit-tree.tsx +152 -0
  114. package/web/format.ts +54 -0
  115. package/web/home-page.tsx +274 -0
  116. package/web/index.html +17 -0
  117. package/web/insight-data.ts +76 -0
  118. package/web/insight-tiles.tsx +534 -0
  119. package/web/insights.tsx +250 -0
  120. package/web/issues-panel.tsx +305 -0
  121. package/web/main.tsx +72 -0
  122. package/web/network-graph.tsx +647 -0
  123. package/web/overview.tsx +339 -0
  124. package/web/pages.tsx +469 -0
  125. package/web/parts.tsx +381 -0
  126. package/web/public/favicon.svg +1 -0
  127. package/web/repo-navigation.tsx +256 -0
  128. package/web/repo-settings.tsx +145 -0
  129. package/web/router.tsx +151 -0
  130. package/web/transport.ts +91 -0
  131. package/web/views.tsx +151 -0
  132. package/web/working-now.tsx +98 -0
@@ -0,0 +1,1264 @@
1
+ import process from "node:process";
2
+ import console from "node:console";
3
+ import { URL } from "node:url";
4
+ import assert from "node:assert/strict";
5
+ import { chromium } from "playwright";
6
+ import { mkdir, writeFile } from "node:fs/promises";
7
+ const phase = process.argv[2] ?? "before";
8
+ const output = new URL(
9
+ `../../../../.botanical/bonsai-shots/${phase}/`,
10
+ import.meta.url,
11
+ );
12
+ await mkdir(output, { recursive: true });
13
+ const browser = await chromium.launch({ headless: true });
14
+ try {
15
+ if (phase.startsWith("scene5-")) {
16
+ const origin = process.argv[3] ?? "http://127.0.0.1:5188";
17
+ const findings = [];
18
+ const railFindings = [];
19
+ const railMeasurements = [];
20
+ const measurements = [];
21
+ for (const width of [1440, 390]) {
22
+ const page = await browser.newPage({
23
+ viewport: { width, height: width === 390 ? 844 : 900 },
24
+ });
25
+ await page.clock.install();
26
+ await page.goto(`${origin}/test/browser/visual.html?tab=changes&scene5`);
27
+ await page.locator('[data-tile="changes"]').waitFor();
28
+ assert.equal(
29
+ await page.locator(".caps-copy-field code").first().textContent(),
30
+ "https://oyster.shiit.app/git/bonsai/koi-pond.git",
31
+ "capture serves this branch's scene5 fixture",
32
+ );
33
+ await page.evaluate(() => globalThis.document.fonts.ready);
34
+ await page.clock.runFor(500);
35
+ const measured = await page.evaluate(() => {
36
+ const nav = globalThis.document.querySelector(
37
+ '[aria-label="Repository sections"]',
38
+ );
39
+ const links = [...nav.querySelectorAll("a")];
40
+ const changes = links.find((link) =>
41
+ link.textContent.startsWith("Changes"),
42
+ );
43
+ const badge = changes.querySelector(".badge");
44
+ const label = globalThis.document.createRange();
45
+ label.selectNodeContents(changes.firstChild);
46
+ const overview = links.find((link) => link.textContent === "Overview");
47
+ const railBounds = nav.getBoundingClientRect();
48
+ const firstVisible = links.find(
49
+ (link) => link.getBoundingClientRect().right > railBounds.left + 1,
50
+ );
51
+ const clone = [
52
+ ...globalThis.document.querySelectorAll(".caps-copy-field"),
53
+ ].find((field) => field.getBoundingClientRect().width > 0);
54
+ const code = clone?.querySelector("code");
55
+ const statuses = [
56
+ ...globalThis.document.querySelectorAll(".list-row .caps-status"),
57
+ ];
58
+ const tile = globalThis.document.querySelector('[data-tile="changes"]');
59
+ const panel = tile.querySelector(".caps-screen") ?? tile;
60
+ const recent = globalThis.document.querySelector(
61
+ '[aria-label*="working now"], [aria-label="Recent repository pushes"]',
62
+ );
63
+ return {
64
+ badgeGap:
65
+ badge.getBoundingClientRect().left -
66
+ label.getBoundingClientRect().right,
67
+ overviewVisible:
68
+ overview.getBoundingClientRect().left >=
69
+ nav.getBoundingClientRect().left - 1,
70
+ leadingTabWhole:
71
+ firstVisible.getBoundingClientRect().left >= railBounds.left - 1,
72
+ cloneLines: code
73
+ ? code.getBoundingClientRect().height /
74
+ Number.parseFloat(globalThis.getComputedStyle(code).lineHeight)
75
+ : null,
76
+ readyInsets: statuses.map((status) => {
77
+ const row = status.closest("li");
78
+ return (
79
+ row.getBoundingClientRect().right -
80
+ Number.parseFloat(globalThis.getComputedStyle(row).paddingRight) -
81
+ status.lastElementChild.getBoundingClientRect().right
82
+ );
83
+ }),
84
+ panelBottomBorder: panel
85
+ ? Number.parseFloat(
86
+ globalThis.getComputedStyle(panel).borderBottomWidth,
87
+ )
88
+ : null,
89
+ recentText: recent?.textContent ?? null,
90
+ };
91
+ });
92
+ measurements.push({ width, ...measured });
93
+ if (measured.badgeGap < 4)
94
+ findings.push(`${width}: Changes badge touches its label`);
95
+ if (!measured.leadingTabWhole)
96
+ findings.push(`${width}: leading tab is partially clipped`);
97
+ if (width === 1440 && measured.cloneLines > 1.1)
98
+ findings.push("1440: clone URL wraps mid-word");
99
+ if (measured.readyInsets.some((inset) => Math.abs(inset) > 2))
100
+ findings.push(`${width}: status is not a trailing column`);
101
+ if (measured.panelBottomBorder === 0)
102
+ findings.push(`${width}: Changes panel has no bottom rule`);
103
+ if (measured.recentText !== "3 pushed recently")
104
+ findings.push(`${width}: push activity is labelled as current work`);
105
+ if (width === 390) {
106
+ const clone = page.getByRole("button", { name: "Clone", exact: true });
107
+ if (await clone.locator(".caps-keycap-led").count())
108
+ findings.push("390: Clone has a stray status lamp");
109
+ }
110
+ await page.screenshot({
111
+ path: new URL(`${width}.png`, output).pathname,
112
+ animations: "disabled",
113
+ });
114
+ if (width === 390) {
115
+ const rail = page.getByRole("navigation", {
116
+ name: "Repository sections",
117
+ });
118
+ const viewport = rail.locator(".caps-navigation-rail-viewport");
119
+ const overflow = await viewport.evaluate(
120
+ (element) => element.scrollWidth > element.clientWidth + 1,
121
+ );
122
+ if (overflow) {
123
+ if (
124
+ !(await rail.getAttribute("data-overflow-start")) &&
125
+ !(await rail.getAttribute("data-overflow-end"))
126
+ )
127
+ railFindings.push(
128
+ "390: overflowing tabs have no scroll affordance",
129
+ );
130
+ for (const link of await rail.getByRole("link").all()) {
131
+ await link.focus();
132
+ await page.clock.runFor(500);
133
+ const readBounds = () =>
134
+ link.evaluate((element) => {
135
+ const bounds = element.getBoundingClientRect();
136
+ const scroller = element.closest(
137
+ ".caps-navigation-rail-viewport",
138
+ );
139
+ const viewport = scroller.getBoundingClientRect();
140
+ return {
141
+ label: element.textContent,
142
+ left: bounds.left,
143
+ right: bounds.right,
144
+ viewportLeft: viewport.left,
145
+ viewportRight: viewport.right,
146
+ scrollLeft: scroller.scrollLeft,
147
+ scrollWidth: scroller.scrollWidth,
148
+ clientWidth: scroller.clientWidth,
149
+ reachable:
150
+ bounds.left >= viewport.left - 1 &&
151
+ bounds.right <= viewport.right + 1,
152
+ };
153
+ });
154
+ let focused = await readBounds();
155
+ railMeasurements.push({ interaction: "focus", ...focused });
156
+ if (!focused.reachable) {
157
+ await viewport.hover();
158
+ await page.mouse.wheel(
159
+ focused.right > focused.viewportRight ? 180 : -180,
160
+ 0,
161
+ );
162
+ await page.clock.runFor(500);
163
+ focused = await readBounds();
164
+ railMeasurements.push({
165
+ interaction: "horizontal wheel",
166
+ ...focused,
167
+ });
168
+ }
169
+ if (!focused.reachable)
170
+ railFindings.push(
171
+ `390: tab ${await link.innerText()} is unreachable`,
172
+ );
173
+ }
174
+ }
175
+ }
176
+ if (phase.endsWith("after")) {
177
+ await page.clock.fastForward(7 * 60_000 + 1);
178
+ await page.clock.runFor(100);
179
+ if (
180
+ await page
181
+ .getByLabel("Recent repository pushes", { exact: true })
182
+ .count()
183
+ )
184
+ findings.push(
185
+ `${width}: activity survives its window without new data`,
186
+ );
187
+ }
188
+ await page.close();
189
+ }
190
+ await writeFile(
191
+ new URL("measurements.json", output),
192
+ JSON.stringify(
193
+ { measurements, findings, railFindings, railMeasurements },
194
+ null,
195
+ 2,
196
+ ),
197
+ );
198
+ assert.deepEqual(
199
+ railFindings,
200
+ [],
201
+ "phone tabs remain reachable with overflow affordance",
202
+ );
203
+ if (phase === "scene5-css") {
204
+ assert.ok(
205
+ measurements.find((item) => item.width === 1440).cloneLines <= 1.1,
206
+ "desktop clone URL remains on one line",
207
+ );
208
+ console.log(
209
+ `PASS clone URL + phone rail ONLY: 1440/390; ${output.pathname}`,
210
+ );
211
+ } else {
212
+ assert.deepEqual(findings, [], "scene 5 visual and activity regressions");
213
+ console.log(`PASS scene 5: 1440/390; ${output.pathname}`);
214
+ }
215
+ } else if (phase.startsWith("live-polish-")) {
216
+ const origin = process.argv[3] ?? "http://127.0.0.1:5188";
217
+ for (const scenario of ["overview", "home", "pending", "error"])
218
+ for (const width of [1440, 390])
219
+ for (const theme of ["light", "dark"]) {
220
+ const page = await browser.newPage({
221
+ viewport: { width, height: 1000 },
222
+ colorScheme: theme,
223
+ });
224
+ const errors = [];
225
+ page.on("pageerror", (error) => errors.push(error.message));
226
+ const tab = scenario === "home" ? "home" : "overview";
227
+ const loading = scenario === "pending" || scenario === "error";
228
+ await page.goto(
229
+ `${origin}/test/browser/visual.html?tab=${tab}${loading ? `&load=${scenario}` : ""}`,
230
+ );
231
+ await page.evaluate(
232
+ (t) =>
233
+ globalThis.document.documentElement.setAttribute("data-theme", t),
234
+ theme,
235
+ );
236
+ await page.locator('[data-tile="network"]').waitFor();
237
+ if (loading) {
238
+ assert.match(
239
+ await page.locator('[data-tile="readme"]').innerText(),
240
+ /Koi pond/u,
241
+ );
242
+ if (scenario === "pending")
243
+ assert.match(
244
+ await page.locator('[data-tile="network"]').innerText(),
245
+ /Loading network/u,
246
+ );
247
+ else
248
+ assert.equal(
249
+ await page
250
+ .locator('[data-tile="network"]')
251
+ .getByRole("button", { name: "Retry", exact: true })
252
+ .count(),
253
+ 1,
254
+ );
255
+ }
256
+ if (scenario === "overview") {
257
+ const gutter = await page
258
+ .locator(".bonsai-network-gutter")
259
+ .boundingBox();
260
+ const plot = await page
261
+ .locator(".bonsai-network-scroll")
262
+ .boundingBox();
263
+ assert.ok(
264
+ gutter && plot && gutter.x + gutter.width <= plot.x + 1,
265
+ "lane labels stay outside the plot",
266
+ );
267
+ const nav = page.getByRole("navigation", {
268
+ name: "Repository sections",
269
+ });
270
+ if (width === 390) {
271
+ const rail = await nav.boundingBox();
272
+ const arrange = await page
273
+ .getByRole("button", { name: "Arrange tiles" })
274
+ .boundingBox();
275
+ assert.ok(
276
+ rail && arrange && arrange.y >= rail.y + rail.height,
277
+ "Arrange sits below phone tabs",
278
+ );
279
+ }
280
+ for (const name of [
281
+ "Overview",
282
+ "Code",
283
+ "History",
284
+ "Changes",
285
+ "Issues",
286
+ ]) {
287
+ const link = nav.getByRole("link", {
288
+ name,
289
+ exact: name !== "Changes" && name !== "Issues",
290
+ });
291
+ await link.scrollIntoViewIfNeeded();
292
+ const box = await link.boundingBox();
293
+ assert.ok(
294
+ box && box.x >= 0 && box.x + box.width <= width,
295
+ `${name} reachable`,
296
+ );
297
+ }
298
+ await nav
299
+ .getByRole("link", { name: "Overview", exact: true })
300
+ .scrollIntoViewIfNeeded();
301
+ await page.evaluate(() => globalThis.scrollTo(0, 0));
302
+ }
303
+ assert.equal(
304
+ await page.evaluate(
305
+ () =>
306
+ globalThis.document.documentElement.scrollWidth <=
307
+ globalThis.innerWidth,
308
+ ),
309
+ true,
310
+ "no viewport overflow",
311
+ );
312
+ assert.deepEqual(errors, []);
313
+ await page.screenshot({
314
+ path: new URL(`${String(width)}-${theme}-${scenario}.png`, output)
315
+ .pathname,
316
+ fullPage: true,
317
+ animations: "disabled",
318
+ });
319
+ await page.close();
320
+ }
321
+ console.log(
322
+ `PASS live polish: progressive loading, Retry, phone navigation and lane spacing; ${output.pathname}`,
323
+ );
324
+ } else if (/^redesign-[5-8]-/u.test(phase)) {
325
+ const slice = phase.split("-")[1];
326
+ const tab = { 5: "commits", 6: "code", 7: "home", 8: "issues" }[slice];
327
+ for (const width of [1440, 390])
328
+ for (const theme of ["light", "dark"]) {
329
+ const page = await browser.newPage({
330
+ viewport: { width, height: 1000 },
331
+ colorScheme: theme,
332
+ });
333
+ const errors = [];
334
+ page.on("pageerror", (error) => errors.push(error.message));
335
+ await page.goto(
336
+ `http://127.0.0.1:5188/test/browser/visual.html?tab=${tab}`,
337
+ );
338
+ await page.locator("main").waitFor();
339
+ await page.evaluate(
340
+ (t) =>
341
+ globalThis.document.documentElement.setAttribute("data-theme", t),
342
+ theme,
343
+ );
344
+ if (slice === "5" && phase.endsWith("after")) {
345
+ const rows = page.locator(".bonsai-history-commits > li");
346
+ await page.getByRole("tab", { name: "Agents", exact: true }).click();
347
+ assert.equal(await rows.count(), 30);
348
+ await page.getByRole("tab", { name: "People", exact: true }).click();
349
+ assert.equal(await rows.count(), 10);
350
+ await page.getByRole("tab", { name: "All", exact: true }).click();
351
+ assert.equal(await rows.count(), 40);
352
+ assert.equal(
353
+ await page
354
+ .locator(".bonsai-history")
355
+ .evaluate(
356
+ (element) => element.scrollHeight <= element.clientHeight,
357
+ ),
358
+ true,
359
+ "history uses page scroll",
360
+ );
361
+ await page.evaluate(() => globalThis.document.activeElement?.blur());
362
+ await page.keyboard.press("j");
363
+ assert.match(
364
+ await page
365
+ .locator('[aria-current="true"]')
366
+ .locator("..")
367
+ .getAttribute("class"),
368
+ /stipe-surface-selected/u,
369
+ );
370
+ assert.match(
371
+ await page.locator('[aria-current="true"]').getAttribute("href"),
372
+ /00000027$/u,
373
+ );
374
+ if (width === 1440) {
375
+ await page
376
+ .locator('[data-tile="commit"] h1')
377
+ .filter({ hasText: "Keep fish inside the reeds" })
378
+ .waitFor();
379
+ } else {
380
+ assert.equal(await page.locator('[data-tile="commit"]').count(), 0);
381
+ await page.keyboard.press("Enter");
382
+ await page
383
+ .locator("h1")
384
+ .filter({ hasText: "Keep fish inside the reeds" })
385
+ .waitFor();
386
+ await page.goto(
387
+ `http://127.0.0.1:5188/test/browser/visual.html?tab=${tab}`,
388
+ );
389
+ await page.getByRole("tab", { name: "All", exact: true }).waitFor();
390
+ }
391
+ }
392
+ if (slice === "6" && phase.endsWith("after")) {
393
+ await page
394
+ .locator('[data-tile="file"]')
395
+ .getByText("Koi pond", { exact: true })
396
+ .waitFor();
397
+ await page.getByText("Latest commit ·", { exact: false }).waitFor();
398
+ await page.getByRole("button", { name: "Raw", exact: true }).click();
399
+ await page
400
+ .locator('[data-tile="file"] pre')
401
+ .filter({ hasText: "# Koi pond" })
402
+ .waitFor();
403
+ await page.getByRole("button", { name: "Raw", exact: true }).click();
404
+ if (width === 390)
405
+ await page
406
+ .getByRole("button", { name: "Files · README.md", exact: true })
407
+ .click();
408
+ const fileRow = page.locator(
409
+ '[data-item-path="src/pond.ts"][role="treeitem"]',
410
+ );
411
+ if (!(await fileRow.isVisible()))
412
+ await page
413
+ .locator('[data-item-path="src"][role="treeitem"]')
414
+ .click();
415
+ await fileRow.click();
416
+ await page
417
+ .locator('[data-tile="file"]')
418
+ .getByText("src/pond.ts", { exact: true })
419
+ .waitFor();
420
+ await page.getByRole("button", { name: "Raw", exact: true }).click();
421
+ await page
422
+ .locator('[data-tile="file"] pre')
423
+ .filter({ hasText: "export const ripples = 5" })
424
+ .waitFor();
425
+ await page.getByRole("button", { name: "Raw", exact: true }).click();
426
+ if (width === 390)
427
+ assert.equal(
428
+ await page
429
+ .getByRole("button", {
430
+ name: "Files · src/pond.ts",
431
+ exact: true,
432
+ })
433
+ .getAttribute("aria-expanded"),
434
+ "false",
435
+ );
436
+ }
437
+ if (slice === "7" && phase.endsWith("after")) {
438
+ await page
439
+ .locator('[data-tile="readme"]')
440
+ .getByRole("heading", { name: "Bonsai", exact: true })
441
+ .waitFor();
442
+ await page.locator('[data-tile="network"] svg').first().waitFor();
443
+ await page
444
+ .locator('[data-tile="repo:koi-pond"]')
445
+ .getByRole("link")
446
+ .waitFor();
447
+ const card = page.locator('[data-tile="repo:koi-pond"]');
448
+ assert.equal(
449
+ await card.getByRole("link").count(),
450
+ 1,
451
+ "whole repository card is one link",
452
+ );
453
+ assert.equal(await card.getByText("Open repository ›").count(), 0);
454
+ if (width === 1440) {
455
+ await page.waitForFunction(() => {
456
+ const network = globalThis.document.querySelector(
457
+ '[data-tile="network"]',
458
+ );
459
+ const live =
460
+ globalThis.document.querySelector('[data-tile="live"]');
461
+ const card = globalThis.document.querySelector(
462
+ '[data-tile="repo:koi-pond"]',
463
+ );
464
+ return (
465
+ Math.abs(
466
+ network.getBoundingClientRect().bottom -
467
+ live.getBoundingClientRect().bottom,
468
+ ) < 2 &&
469
+ Math.abs(
470
+ card.getBoundingClientRect().top -
471
+ network.getBoundingClientRect().bottom -
472
+ 12,
473
+ ) < 2
474
+ );
475
+ });
476
+ }
477
+ await page.waitForFunction(() =>
478
+ [
479
+ ...globalThis.document.querySelectorAll(
480
+ '[data-tile="readme"] img',
481
+ ),
482
+ ].every((img) => img.complete && img.naturalWidth > 0),
483
+ );
484
+ }
485
+ if (slice === "8" && phase.endsWith("after")) {
486
+ for (const name of ["Unblocked", "Blocked", "Done"])
487
+ await page.getByRole("heading", { name, exact: true }).waitFor();
488
+ assert.equal(
489
+ await page
490
+ .getByRole("heading", {
491
+ name: "Issue files need fixing",
492
+ exact: true,
493
+ })
494
+ .count(),
495
+ 0,
496
+ );
497
+ const blocked = page
498
+ .getByRole("heading", { name: "Blocked", exact: true })
499
+ .locator("..");
500
+ await blocked
501
+ .getByRole("link", { name: "Plant reeds", exact: true })
502
+ .waitFor();
503
+ assert.equal(
504
+ await blocked.getByText("ready · agent", { exact: true }).count(),
505
+ 0,
506
+ );
507
+ await blocked
508
+ .locator(".caps-status")
509
+ .filter({ hasText: "blocked by pond-01" })
510
+ .waitFor();
511
+ const done = page
512
+ .getByRole("heading", { name: "Done", exact: true })
513
+ .locator("..");
514
+ assert.equal(
515
+ await done.locator(".caps-status").getAttribute("data-glyph"),
516
+ "dot",
517
+ );
518
+ const titleBox = await blocked
519
+ .getByRole("link", { name: "Plant reeds", exact: true })
520
+ .boundingBox();
521
+ const statusBox = await blocked.locator(".caps-status").boundingBox();
522
+ assert.ok(
523
+ titleBox.x < statusBox.x,
524
+ "issue title precedes trailing status",
525
+ );
526
+ await blocked
527
+ .getByRole("link", { name: "pond-01", exact: true })
528
+ .click();
529
+ await page
530
+ .getByRole("heading", { name: "Add a lily pad", exact: true })
531
+ .waitFor();
532
+ await page
533
+ .getByRole("link", { name: "Issues", exact: true })
534
+ .last()
535
+ .click();
536
+ await page
537
+ .getByRole("heading", { name: "Blocked", exact: true })
538
+ .waitFor();
539
+ await page.waitForFunction(
540
+ () => globalThis.document.documentElement.scrollHeight <= 1200,
541
+ );
542
+ }
543
+ await page.screenshot({
544
+ path: new URL(`${width}-${theme}-${tab}.png`, output).pathname,
545
+ fullPage: true,
546
+ animations: "disabled",
547
+ });
548
+ if (slice === "7" && phase.endsWith("after")) {
549
+ await page.screenshot({
550
+ path: new URL(`${width}-${theme}-${tab}-viewport.png`, output)
551
+ .pathname,
552
+ animations: "disabled",
553
+ });
554
+ await page
555
+ .locator('[data-tile="repo:koi-pond"]')
556
+ .getByRole("link")
557
+ .focus();
558
+ await page.keyboard.press("Enter");
559
+ assert.equal(
560
+ await page
561
+ .getByRole("link", { name: "Overview", exact: true })
562
+ .getAttribute("aria-current"),
563
+ "page",
564
+ );
565
+ }
566
+ assert.deepEqual(errors, []);
567
+ assert.equal(
568
+ await page.evaluate(
569
+ () =>
570
+ globalThis.document.documentElement.scrollWidth <=
571
+ globalThis.innerWidth,
572
+ ),
573
+ true,
574
+ );
575
+ await page.close();
576
+ }
577
+ console.log(
578
+ `PASS ${phase}: 1440/390 light/dark screenshots, viewport fit, no page errors`,
579
+ );
580
+ } else if (phase.startsWith("redesign-4")) {
581
+ for (const width of [1440, 390])
582
+ for (const theme of phase.includes("-pr-")
583
+ ? ["latte", "mocha"]
584
+ : ["light", "dark"])
585
+ for (const state of ["ready", "conflict"]) {
586
+ const page = await browser.newPage({
587
+ viewport: { width, height: 1000 },
588
+ colorScheme:
589
+ theme === "mocha" || theme === "dark" ? "dark" : "light",
590
+ });
591
+ page.on("pageerror", (error) => console.error(error.message));
592
+ await page.goto(
593
+ `http://127.0.0.1:5188/test/browser/visual.html?change=${state}`,
594
+ );
595
+ await page.locator("h1").waitFor();
596
+ await page.evaluate(
597
+ (t) =>
598
+ globalThis.document.documentElement.setAttribute("data-theme", t),
599
+ theme,
600
+ );
601
+ if (phase.endsWith("after")) {
602
+ assert.equal(await page.locator("[data-tile]").count(), 6);
603
+ assert.equal(
604
+ await page
605
+ .getByRole("button", { name: "Merge into main", exact: true })
606
+ .isDisabled(),
607
+ state === "conflict",
608
+ );
609
+ assert.equal(
610
+ await page.evaluate(
611
+ () =>
612
+ globalThis.document.documentElement.scrollWidth <=
613
+ globalThis.innerWidth,
614
+ ),
615
+ true,
616
+ "Change fits viewport",
617
+ );
618
+ await page
619
+ .getByText("before the latest push", { exact: false })
620
+ .waitFor();
621
+ await page.getByText("1 file ·", { exact: false }).waitFor();
622
+ if (state === "conflict")
623
+ await page
624
+ .getByText("Update Luna/main from main", { exact: false })
625
+ .waitFor();
626
+ assert.match(
627
+ await page
628
+ .getByRole("button", { name: "Merge into main", exact: true })
629
+ .getAttribute("class"),
630
+ /btn-primary caps-button-filled/,
631
+ );
632
+ await page
633
+ .getByRole("heading", {
634
+ name: "#1 Add ripples around the koi",
635
+ exact: true,
636
+ })
637
+ .waitFor();
638
+ await page
639
+ .getByRole("button", { name: "Approve", exact: true })
640
+ .waitFor();
641
+ await page
642
+ .getByRole("button", { name: "Close", exact: true })
643
+ .waitFor();
644
+ assert.equal(
645
+ await page
646
+ .getByRole("button", { name: "Re-run checks", exact: true })
647
+ .count(),
648
+ 0,
649
+ );
650
+ await page.getByText("1 of 1 passed", { exact: true }).waitFor();
651
+ const actionHeights = await Promise.all(
652
+ ["Approve", "Close", "Merge into main"].map(async (name) => {
653
+ const box = await page
654
+ .getByRole("button", { name, exact: true })
655
+ .boundingBox();
656
+ assert.ok(box);
657
+ return box.height;
658
+ }),
659
+ );
660
+ assert.equal(
661
+ new Set(actionHeights).size,
662
+ 1,
663
+ "actions share one Caps button height",
664
+ );
665
+ await page
666
+ .getByRole("link", { name: "Next change #2", exact: true })
667
+ .waitFor();
668
+
669
+ if (state === "conflict") {
670
+ const reason = page.locator(
671
+ '[data-tile="head"] span[tabindex="0"]',
672
+ );
673
+ await reason.focus();
674
+ await page
675
+ .getByRole("tooltip")
676
+ .filter({ hasText: "Resolve the conflicts" })
677
+ .waitFor();
678
+ await page
679
+ .getByRole("button", { name: "Close", exact: true })
680
+ .focus();
681
+ await page.getByRole("tooltip").waitFor({ state: "hidden" });
682
+ }
683
+ const labels = page.locator(
684
+ '[data-tile="lane"] .bonsai-network-lane',
685
+ );
686
+ await labels.locator(".bonsai-lane-owner").nth(1).waitFor();
687
+ assert.deepEqual(
688
+ await labels.locator(".bonsai-lane-owner").allTextContents(),
689
+ ["main", "Luna"],
690
+ );
691
+ assert.equal(
692
+ await labels.last().getAttribute("title"),
693
+ "koi-pond--Luna/main",
694
+ );
695
+ for (const [above, below] of width === 1440
696
+ ? [
697
+ ["lane", "commits"],
698
+ ["diff", "review"],
699
+ ["commits", "files"],
700
+ ]
701
+ : [
702
+ ["review", "diff"],
703
+ ["diff", "lane"],
704
+ ["lane", "commits"],
705
+ ["commits", "files"],
706
+ ]) {
707
+ await page.waitForFunction(
708
+ ([a, b]) => {
709
+ const first = globalThis.document
710
+ .querySelector(`[data-tile="${a}"] > :first-child`)
711
+ .getBoundingClientRect();
712
+ const second = globalThis.document
713
+ .querySelector(`[data-tile="${b}"]`)
714
+ .getBoundingClientRect();
715
+ const gap = second.y - first.bottom;
716
+ return gap >= 10 && gap <= 14;
717
+ },
718
+ [above, below],
719
+ );
720
+ }
721
+ }
722
+ await page.screenshot({
723
+ path: new URL(`${width}-${theme}-${state}.png`, output).pathname,
724
+ fullPage: true,
725
+ animations: "disabled",
726
+ timeout: 60_000,
727
+ });
728
+ await page.close();
729
+ }
730
+ if (phase.endsWith("after")) {
731
+ for (const result of [
732
+ "merged",
733
+ "moved",
734
+ "conflict",
735
+ "refused",
736
+ "error",
737
+ ]) {
738
+ const page = await browser.newPage({
739
+ viewport: { width: 390, height: 1000 },
740
+ });
741
+ const errors = [];
742
+ page.on("pageerror", (error) => errors.push(error.message));
743
+ await page.goto(
744
+ `http://127.0.0.1:5188/test/browser/visual.html?change=ready&result=${result}`,
745
+ );
746
+ await page
747
+ .getByRole("button", { name: "Merge into main", exact: true })
748
+ .click();
749
+ const message = {
750
+ merged: "Merged as",
751
+ moved: "The reviewed head moved",
752
+ conflict: "Conflicts in",
753
+ refused: "Merge refused",
754
+ error: "Fixture merge unavailable",
755
+ }[result];
756
+ await page
757
+ .getByRole(result === "error" ? "alert" : "status")
758
+ .filter({ hasText: message })
759
+ .waitFor();
760
+ assert.deepEqual(
761
+ await page.evaluate(() =>
762
+ JSON.parse(globalThis.localStorage.getItem("fixture-merge")),
763
+ ),
764
+ {
765
+ repo: "koi-pond",
766
+ number: 1,
767
+ head: "7c3c104600000000000000000000000000000026",
768
+ },
769
+ "merge uses numbered identity and exact displayed head",
770
+ );
771
+ if (result === "merged" || result === "conflict")
772
+ assert.equal(
773
+ await page
774
+ .getByRole("button", { name: "Merge into main", exact: true })
775
+ .isDisabled(),
776
+ true,
777
+ );
778
+ await page.keyboard.press("]");
779
+ await page
780
+ .getByRole("heading", {
781
+ name: "#2 Keep fish inside the reeds",
782
+ exact: true,
783
+ })
784
+ .waitFor();
785
+ await page.keyboard.press("[");
786
+ await page
787
+ .getByRole("heading", {
788
+ name: "#1 Add ripples around the koi",
789
+ exact: true,
790
+ })
791
+ .waitFor();
792
+ assert.deepEqual(errors, [], "no Change page errors");
793
+ await page.close();
794
+ }
795
+ }
796
+ if (phase.includes("-pr-") && phase.endsWith("after")) {
797
+ const page = await browser.newPage({
798
+ viewport: { width: 390, height: 1000 },
799
+ });
800
+ const errors = [];
801
+ page.on("pageerror", (error) => errors.push(error.message));
802
+ await page.goto(
803
+ "http://127.0.0.1:5188/test/browser/visual.html?change=ready#change/koi-pond/1",
804
+ );
805
+ await page.getByRole("button", { name: "Approve", exact: true }).click();
806
+ await page
807
+ .getByRole("status")
808
+ .filter({ hasText: "Approved 7c3c104" })
809
+ .waitFor();
810
+ assert.deepEqual(
811
+ await page.evaluate(() =>
812
+ JSON.parse(globalThis.localStorage.getItem("fixture-approval")),
813
+ ),
814
+ {
815
+ repo: "koi-pond",
816
+ number: 1,
817
+ head: "7c3c104600000000000000000000000000000026",
818
+ },
819
+ );
820
+ await page.getByRole("button", { name: "Close", exact: true }).click();
821
+ await page
822
+ .getByRole("status")
823
+ .filter({ hasText: "Change closed." })
824
+ .waitFor();
825
+ assert.deepEqual(
826
+ await page.evaluate(() =>
827
+ JSON.parse(globalThis.localStorage.getItem("fixture-close")),
828
+ ),
829
+ { repo: "koi-pond", number: 1 },
830
+ );
831
+ assert.equal(
832
+ await page
833
+ .getByRole("button", { name: "Approve", exact: true })
834
+ .count(),
835
+ 0,
836
+ );
837
+ assert.equal(
838
+ await page.getByRole("button", { name: "Close", exact: true }).count(),
839
+ 0,
840
+ );
841
+ assert.equal(
842
+ await page
843
+ .getByRole("button", { name: "Merge into main", exact: true })
844
+ .isDisabled(),
845
+ true,
846
+ );
847
+ assert.deepEqual(errors, []);
848
+ await page.close();
849
+ }
850
+ console.log(
851
+ `PASS ${phase}: Change ready/conflict shots 1440/390 ${phase.includes("-pr-") ? "Latte/Mocha; approve/close actions;" : "light/dark;"} numbered merge outcomes and keyboard neighbours`,
852
+ );
853
+ } else {
854
+ for (const width of [1440, 390])
855
+ for (const theme of ["light", "dark"]) {
856
+ const page = await browser.newPage({
857
+ viewport: { width, height: 1000 },
858
+ colorScheme: theme,
859
+ });
860
+ const errors = [];
861
+ page.on("pageerror", (error) => errors.push(error.message));
862
+ await page.goto(
863
+ "http://127.0.0.1:5188/" +
864
+ (phase === "before"
865
+ ? ".botanical/before/visual.html"
866
+ : "test/browser/visual.html"),
867
+ );
868
+ await page.locator("h1").filter({ hasText: "koi-pond" }).waitFor();
869
+ await page.evaluate(
870
+ (t) =>
871
+ globalThis.document.documentElement.setAttribute("data-theme", t),
872
+ theme,
873
+ );
874
+ if (phase !== "before") {
875
+ assert.equal(
876
+ await page
877
+ .getByRole("button", { name: "G O", exact: true })
878
+ .count(),
879
+ 0,
880
+ );
881
+ await page.locator(".bonsai-history-commits").evaluate((list) => {
882
+ for (const row of list.children) {
883
+ const title = row.querySelector("a > span:first-child");
884
+ const branch = row.querySelector(".bonsai-history-branch");
885
+ if (
886
+ !title ||
887
+ !branch ||
888
+ branch.getBoundingClientRect().left <
889
+ title.getBoundingClientRect().right
890
+ )
891
+ throw new Error("Branch label overlaps commit title");
892
+ }
893
+ });
894
+ await page.keyboard.press("g");
895
+ await page.keyboard.press("o");
896
+ await page
897
+ .locator('[aria-current="page"]')
898
+ .filter({ hasText: "Overview" })
899
+ .waitFor();
900
+ await page.keyboard.press("g");
901
+ await page.keyboard.press("h");
902
+ await page
903
+ .locator('[aria-current="page"]')
904
+ .filter({ hasText: "History" })
905
+ .waitFor();
906
+ if (width === 390) {
907
+ assert.ok(
908
+ (await page.locator("h1").boundingBox()).height < 60,
909
+ "repository name remains readable on phones",
910
+ );
911
+ await page
912
+ .getByRole("button", { name: "Clone", exact: true })
913
+ .click();
914
+ await page
915
+ .getByRole("region", { name: "Clone repository" })
916
+ .waitFor();
917
+ await page.keyboard.press("Escape");
918
+ assert.equal(
919
+ await page
920
+ .getByRole("region", { name: "Clone repository" })
921
+ .count(),
922
+ 0,
923
+ );
924
+ }
925
+ await page
926
+ .context()
927
+ .grantPermissions(["clipboard-read", "clipboard-write"]);
928
+ await page.keyboard.press("c");
929
+ await page.waitForFunction(
930
+ async () =>
931
+ (await globalThis.navigator.clipboard.readText()) ===
932
+ "https://example.test/git/koi-pond",
933
+ );
934
+ await page.keyboard.press("Escape");
935
+ }
936
+ await page.screenshot({
937
+ path: new URL(`${width}-${theme}-history.png`, output).pathname,
938
+ fullPage: true,
939
+ animations: "disabled",
940
+ timeout: 60_000,
941
+ });
942
+ if (phase !== "before") {
943
+ assert.equal(
944
+ await page.locator(".bonsai-history-commits li").count(),
945
+ 40,
946
+ );
947
+ await page
948
+ .getByRole("link", { name: "Overview", exact: true })
949
+ .click();
950
+ await page.locator('[data-tile="contributors"]').waitFor();
951
+ assert.equal(await page.locator("[data-tile]").count(), 8);
952
+ await page.waitForFunction(() => {
953
+ const window = globalThis.document.querySelector(
954
+ ".bonsai-network-scroll",
955
+ );
956
+ const newest = window?.querySelector(
957
+ ".bonsai-bead:last-child circle",
958
+ );
959
+ if (!window || !newest) return false;
960
+ const frame = window.getBoundingClientRect();
961
+ const bead = newest.getBoundingClientRect();
962
+ return bead.left >= frame.left && bead.right <= frame.right;
963
+ });
964
+ assert.equal(
965
+ await page
966
+ .locator('[data-tile="ages"], [data-tile="churn"]')
967
+ .count(),
968
+ 0,
969
+ );
970
+ for (const name of [".bonsai-contributor-chart"]) {
971
+ await page.waitForFunction((selector) => {
972
+ const chart = globalThis.document.querySelector(selector);
973
+ const axis = chart?.querySelector("[data-time-axis]");
974
+ return (
975
+ chart &&
976
+ axis &&
977
+ Math.abs(
978
+ chart.getBoundingClientRect().width -
979
+ axis.getBoundingClientRect().width,
980
+ ) < 1
981
+ );
982
+ }, name);
983
+ }
984
+ assert.equal(
985
+ await page.evaluate(
986
+ () =>
987
+ globalThis.document.documentElement.scrollWidth <=
988
+ globalThis.innerWidth,
989
+ ),
990
+ true,
991
+ "page fits viewport",
992
+ );
993
+ assert.equal(await page.locator('[data-tile="branches"]').count(), 0);
994
+ await page.waitForFunction(() => {
995
+ const chart = globalThis.document.querySelector(
996
+ ".bonsai-contributor-chart",
997
+ );
998
+ const paths = [...chart.querySelectorAll("path")];
999
+ return paths.some(
1000
+ (path) =>
1001
+ path.getBBox().x < 1 &&
1002
+ path.getBBox().width >= chart.viewBox.baseVal.width - 1,
1003
+ );
1004
+ });
1005
+ const calendar = await page
1006
+ .locator('[data-tile="calendar"] > :first-child')
1007
+ .boundingBox();
1008
+ assert.ok(
1009
+ calendar && calendar.height < 330,
1010
+ "calendar fits its content",
1011
+ );
1012
+ if (phase === "redesign-9-after") {
1013
+ await page.locator(".bonsai-feed-item").evaluateAll((rows) => {
1014
+ for (const row of rows) {
1015
+ const lines = row.querySelectorAll("p");
1016
+ if (
1017
+ lines.length !== 2 ||
1018
+ row.getBoundingClientRect().height > 70
1019
+ )
1020
+ throw new Error("Live activity must fit two compact lines");
1021
+ }
1022
+ });
1023
+ await page.emulateMedia({ reducedMotion: "reduce" });
1024
+ assert.equal(
1025
+ await page
1026
+ .locator(".bonsai-recent-head")
1027
+ .evaluateAll((heads) =>
1028
+ heads.every(
1029
+ (head) =>
1030
+ globalThis.globalThis.getComputedStyle(head)
1031
+ .animationName === "none",
1032
+ ),
1033
+ ),
1034
+ true,
1035
+ );
1036
+ await page.emulateMedia({ reducedMotion: "no-preference" });
1037
+ if (width === 1440 && theme === "light") {
1038
+ await page.locator('[data-tile="network"]').screenshot({
1039
+ path: new URL(
1040
+ "../../../../packages/oyster/docs/media/bonsai-network.png",
1041
+ import.meta.url,
1042
+ ).pathname,
1043
+ animations: "disabled",
1044
+ });
1045
+ await page.screenshot({
1046
+ path: new URL(
1047
+ "../../../../packages/oyster/docs/media/bonsai-repository.png",
1048
+ import.meta.url,
1049
+ ).pathname,
1050
+ animations: "disabled",
1051
+ });
1052
+ await page.screenshot({
1053
+ path: new URL(
1054
+ "../../docs/media/bonsai-insights.png",
1055
+ import.meta.url,
1056
+ ).pathname,
1057
+ fullPage: true,
1058
+ animations: "disabled",
1059
+ });
1060
+ }
1061
+ }
1062
+ await page.screenshot({
1063
+ path: new URL(`${width}-${theme}-insights.png`, output).pathname,
1064
+ fullPage: true,
1065
+ animations: "disabled",
1066
+ timeout: 60_000,
1067
+ });
1068
+ await page.getByRole("button", { name: "Arrange tiles" }).click();
1069
+ const contributor = page.locator('[data-tile="contributors"]');
1070
+ for (const title of ["README", "Contribution calendar"])
1071
+ await page
1072
+ .getByRole("button", { name: `Remove ${title}`, exact: true })
1073
+ .click();
1074
+ if (width === 1440) {
1075
+ await page
1076
+ .getByRole("button", { name: "Move Contributors", exact: true })
1077
+ .evaluate((element) =>
1078
+ element.scrollIntoView({ block: "center" }),
1079
+ );
1080
+ const handle = await page
1081
+ .getByRole("button", { name: "Move Contributors", exact: true })
1082
+ .boundingBox();
1083
+ assert.ok(handle);
1084
+ const original = await contributor.boundingBox();
1085
+ await page.mouse.move(
1086
+ handle.x + handle.width / 2,
1087
+ handle.y + handle.height / 2,
1088
+ );
1089
+ await page.mouse.down();
1090
+ await page.mouse.move(
1091
+ handle.x + handle.width / 2 + (original?.width ?? 0) + 12,
1092
+ handle.y + handle.height / 2,
1093
+ { steps: 8 },
1094
+ );
1095
+ await page.mouse.up();
1096
+ assert.notEqual(
1097
+ (await contributor.boundingBox())?.x,
1098
+ original?.x,
1099
+ "pointer drag moves tile",
1100
+ );
1101
+ }
1102
+ if (width === 1440) {
1103
+ await page
1104
+ .getByRole("button", { name: "Cancel", exact: true })
1105
+ .click();
1106
+ await page.getByRole("button", { name: "Arrange tiles" }).click();
1107
+ for (const title of ["README", "Contribution calendar"])
1108
+ await page
1109
+ .getByRole("button", { name: `Remove ${title}`, exact: true })
1110
+ .click();
1111
+ }
1112
+ const firstTop = await contributor.evaluate(
1113
+ (element) => element.offsetLeft + element.offsetTop,
1114
+ );
1115
+ await page
1116
+ .getByRole("button", { name: "Move Contributors", exact: true })
1117
+ .focus();
1118
+ await page.keyboard.press(width === 1440 ? "ArrowRight" : "ArrowUp");
1119
+ const moved = await contributor.boundingBox();
1120
+ assert.notEqual(
1121
+ await contributor.evaluate(
1122
+ (element) => element.offsetLeft + element.offsetTop,
1123
+ ),
1124
+ firstTop,
1125
+ "keyboard movement moves tile",
1126
+ );
1127
+ await page
1128
+ .getByRole("button", { name: "Resize Contributors", exact: true })
1129
+ .focus();
1130
+ await page.keyboard.press("ArrowDown");
1131
+ await page.keyboard.press("ArrowDown");
1132
+ const resized = await contributor.boundingBox();
1133
+ assert.ok(
1134
+ (resized?.height ?? 0) > (moved?.height ?? 0),
1135
+ "keyboard resize grows tile",
1136
+ );
1137
+ assert.equal(await page.locator('[data-tile="ages"]').count(), 0);
1138
+ await page
1139
+ .getByRole("button", { name: "Save layout", exact: true })
1140
+ .click();
1141
+ await page.getByRole("button", { name: "Arrange tiles" }).waitFor();
1142
+ await page.reload();
1143
+ await page
1144
+ .getByRole("link", { name: "Overview", exact: true })
1145
+ .click();
1146
+ await contributor.waitFor();
1147
+ assert.equal(
1148
+ await page.locator('[data-tile="ages"]').count(),
1149
+ 0,
1150
+ "removed tile stays removed after reload",
1151
+ );
1152
+ assert.equal(
1153
+ await page.evaluate(
1154
+ () =>
1155
+ JSON.parse(
1156
+ globalThis.localStorage.getItem("fixture-layout"),
1157
+ ).tiles.placements.find((tile) => tile.id === "contributors").h,
1158
+ ),
1159
+ 3,
1160
+ "saved row span survives reload",
1161
+ );
1162
+ assert.ok(
1163
+ (await contributor.boundingBox())?.height >= 528,
1164
+ "reloaded tile retains at least its three-row height",
1165
+ );
1166
+ await page.getByRole("button", { name: "Arrange tiles" }).click();
1167
+ await page.getByLabel("Visualization to add").selectOption("ages");
1168
+ await page.getByRole("button", { name: "Add", exact: true }).click();
1169
+ await page.locator('[data-tile="ages"]').waitFor();
1170
+ await page.waitForFunction(() => {
1171
+ const chart =
1172
+ globalThis.document.querySelector(".bonsai-age-chart");
1173
+ const axis = chart?.querySelector("[data-time-axis]");
1174
+ return (
1175
+ chart &&
1176
+ axis &&
1177
+ Math.abs(
1178
+ chart.getBoundingClientRect().width -
1179
+ axis.getBoundingClientRect().width,
1180
+ ) < 1
1181
+ );
1182
+ });
1183
+ await page
1184
+ .getByRole("button", { name: "Cancel", exact: true })
1185
+ .click();
1186
+ assert.equal(
1187
+ await page.locator('[data-tile="ages"]').count(),
1188
+ 0,
1189
+ "cancel discards unsaved changes",
1190
+ );
1191
+ }
1192
+ assert.deepEqual(errors, [], "no page errors");
1193
+ await page.close();
1194
+ }
1195
+ if (phase !== "before") {
1196
+ const page = await browser.newPage({
1197
+ viewport: { width: 390, height: 1000 },
1198
+ });
1199
+ await page.goto(
1200
+ "http://127.0.0.1:5188/test/browser/visual.html?ref=main",
1201
+ );
1202
+ await page.locator(".bonsai-history-commits li").first().waitFor();
1203
+ assert.equal(
1204
+ await page.locator(".bonsai-history-commits li").count(),
1205
+ 10,
1206
+ "selected branch limits history",
1207
+ );
1208
+ await page.locator(".bonsai-history").evaluate((element) => {
1209
+ element.style.width = "280px";
1210
+ });
1211
+ assert.equal(
1212
+ await page
1213
+ .locator(".bonsai-history")
1214
+ .evaluate((element) => element.scrollWidth <= element.clientWidth),
1215
+ true,
1216
+ "history fits a sidebar",
1217
+ );
1218
+ await page.goto(
1219
+ "http://127.0.0.1:5188/test/browser/visual.html?tab=insights",
1220
+ );
1221
+ await page
1222
+ .locator('[aria-current="page"]')
1223
+ .filter({ hasText: "Overview" })
1224
+ .waitFor();
1225
+ assert.equal(
1226
+ await page.getByRole("link", { name: "Insights", exact: true }).count(),
1227
+ 0,
1228
+ );
1229
+ await page.goto("http://127.0.0.1:5188/test/browser/visual.html?empty");
1230
+ await page.getByRole("link", { name: "Overview", exact: true }).click();
1231
+ await page
1232
+ .getByText("Push a first commit to start this repository.", {
1233
+ exact: false,
1234
+ })
1235
+ .waitFor();
1236
+ assert.equal(await page.locator("[data-tile]").count(), 0);
1237
+ await page.goto(
1238
+ "http://127.0.0.1:5188/test/browser/visual.html?save-error",
1239
+ );
1240
+ await page.getByRole("link", { name: "Overview", exact: true }).click();
1241
+ await page.getByRole("button", { name: "Arrange tiles" }).click();
1242
+ await page
1243
+ .getByRole("button", { name: "Remove Contributors", exact: true })
1244
+ .click();
1245
+ await page
1246
+ .getByRole("button", { name: "Save layout", exact: true })
1247
+ .click();
1248
+ await page.getByText("Fixture save unavailable").waitFor();
1249
+ assert.equal(
1250
+ await page.locator('[data-tile="contributors"]').count(),
1251
+ 0,
1252
+ "failed saves retain the edit for retry",
1253
+ );
1254
+ await page.getByRole("button", { name: "Cancel", exact: true }).click();
1255
+ await page.locator('[data-tile="contributors"]').waitFor();
1256
+ await page.close();
1257
+ }
1258
+ console.log(
1259
+ `PASS ${phase}: 1440/390 light/dark screenshots${phase === "before" ? "" : ", pointer and keyboard move/resize, add/remove, cancel, save/reload, viewport fit, empty and save-error states"}`,
1260
+ );
1261
+ }
1262
+ } finally {
1263
+ await browser.close();
1264
+ }