@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,23 @@
1
+ /** Bonsai picker methods share Gills' maintained placement and choice grammar. */
2
+ import * as Schema from "effect/Schema";
3
+ import * as z from "zod";
4
+ import {
5
+ gillParamsSchema,
6
+ gillOptionsResponseSchema,
7
+ } from "@fungi.computer/gills";
8
+
9
+ const decodeParams = Schema.decodeUnknownSync(gillParamsSchema, {
10
+ onExcessProperty: "error",
11
+ });
12
+ const params = z
13
+ .fromJSONSchema(Schema.toJsonSchemaDocument(gillParamsSchema).schema)
14
+ .transform((value) => decodeParams(value));
15
+ export const pickerOptionsInput = z.strictObject({
16
+ params,
17
+ limit: z.number().int().min(1).max(200),
18
+ after: z.string().max(1024).optional(),
19
+ });
20
+ export type PickerOptionsInput = z.output<typeof pickerOptionsInput>;
21
+ export const pickerOptionsPage = z.fromJSONSchema(
22
+ Schema.toJsonSchemaDocument(gillOptionsResponseSchema).schema,
23
+ );
@@ -0,0 +1,21 @@
1
+ import type { StatusTone } from "@fungi.computer/caps/components/status";
2
+ import type { Change } from "./contract.js";
3
+
4
+ /** The same App-owned status labels and tones serve lists and Gills. */
5
+ export function changeStatusLabel(status: Change["status"]): string {
6
+ const label = status.replaceAll("-", " ");
7
+ return label.charAt(0).toUpperCase() + label.slice(1);
8
+ }
9
+
10
+ export const changeStatusTone = {
11
+ conflict: "warning",
12
+ "checks-failed": "error",
13
+ "checks-running": "info",
14
+ "needs-approval": "warning",
15
+ ready: "success",
16
+ merging: "info",
17
+ merged: "success",
18
+ closed: "neutral",
19
+ } as const satisfies Record<Change["status"], StatusTone>;
20
+
21
+ export { relativeTime } from "@fungi.computer/gills/presentation";
@@ -0,0 +1,4 @@
1
+ import * as z from "zod";
2
+
3
+ /** Successful deletion confirms both catalog withdrawal and provider cleanup. */
4
+ export const repositoryDeletion = z.strictObject({ deleted: z.literal(true) });
@@ -0,0 +1,302 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { describe, expect, it } from "vitest";
3
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
4
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
5
+ import type { GitBinding } from "@fungi.computer/agar/git";
6
+ import { appBackend } from "../backend/methods.js";
7
+ import { gitStore } from "../backend/git.js";
8
+ import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
9
+ import type { Bonsai } from "../backend/service.js";
10
+ import {
11
+ appBackendBindingOperation,
12
+ scopeOperationToMethod,
13
+ } from "@fungi.computer/puffball/app";
14
+
15
+ type Calls = {
16
+ build?: unknown;
17
+ buildStatus?: unknown;
18
+ select?: unknown;
19
+ };
20
+
21
+ /** apps.build/apps.buildStatus/apps.install never touch Git or Bonsai's own
22
+ * repository state; a real (empty) BonsaiState plus an always-unavailable
23
+ * GitBinding give a fully typed Bonsai without faking its shape. apps.fork
24
+ * reads the one repository the host forked, through `git`. */
25
+ function bonsaiFixture(git: Partial<GitBinding> = {}): Bonsai {
26
+ const database = new DatabaseSync(":memory:");
27
+ const owner = fromNodeSqlite(database);
28
+ ensureBonsaiTables(owner.sql);
29
+ const unavailable = () =>
30
+ Promise.reject(new Error("Unexpected provider operation"));
31
+ const binding: GitBinding = {
32
+ create: unavailable,
33
+ fork: unavailable,
34
+ cloneUrl: unavailable,
35
+ info: unavailable,
36
+ delete: unavailable,
37
+ log: unavailable,
38
+ readCommit: unavailable,
39
+ readTree: unavailable,
40
+ readBlob: unavailable,
41
+ refs: unavailable,
42
+ exchange: unavailable,
43
+ ...git,
44
+ };
45
+ return {
46
+ state: new BonsaiState(owner.sql, owner.transactionSync),
47
+ store: gitStore(binding),
48
+ events: { emit: () => Promise.resolve() },
49
+ };
50
+ }
51
+
52
+ function fixture() {
53
+ const calls: Calls = {};
54
+ const build = {
55
+ jobId: "build_test",
56
+ appId: "app_my-fork",
57
+ releaseId: "rel_test",
58
+ sourceRevision: `git:${"a".repeat(40)}`,
59
+ status: "running" as const,
60
+ detail: null,
61
+ release: null,
62
+ };
63
+ const release = {
64
+ appId: "app_my-fork",
65
+ releaseId: "rel_test",
66
+ artifact: {
67
+ artifactRef: "artifact:root",
68
+ artifactDigest: `sha256:${"b".repeat(64)}`,
69
+ provenance: {
70
+ sourceRevision: build.sourceRevision,
71
+ sourceTreeDigest: `sha256:${"c".repeat(64)}`,
72
+ buildRecipeDigest: `sha256:${"d".repeat(64)}`,
73
+ },
74
+ },
75
+ };
76
+ const env = {
77
+ APPS: {
78
+ fork: () => Promise.reject(new Error("This proof never forks")),
79
+ build: (input: { appId: string; sourceRevision: string }) => {
80
+ calls.build = input;
81
+ return Promise.resolve({ status: 202, body: build });
82
+ },
83
+ buildStatus: (input: { jobId: string }) => {
84
+ calls.buildStatus = input;
85
+ return Promise.resolve({
86
+ status: 200,
87
+ body:
88
+ input.jobId === "build_running"
89
+ ? { ...build, jobId: input.jobId }
90
+ : { ...build, status: "succeeded" as const, release },
91
+ });
92
+ },
93
+ select: (input: { installationId: string; release: unknown }) => {
94
+ calls.select = input;
95
+ return Promise.resolve({
96
+ status: 200,
97
+ body: { installationId: input.installationId, status: "enabled" },
98
+ });
99
+ },
100
+ },
101
+ };
102
+ const bonsai = bonsaiFixture();
103
+ const owner = (
104
+ method: string,
105
+ input: import("../shared/contract.js").JsonValue,
106
+ ) =>
107
+ appBackend.call(
108
+ method,
109
+ input,
110
+ { bonsai, env },
111
+ {
112
+ actor: { kind: "member", memberId: "Levi" },
113
+ computerId: null,
114
+ role: "owner" as const,
115
+ },
116
+ );
117
+ const member = (
118
+ method: string,
119
+ input: import("../shared/contract.js").JsonValue,
120
+ ) =>
121
+ appBackend.call(
122
+ method,
123
+ input,
124
+ { bonsai, env },
125
+ {
126
+ actor: { kind: "member", memberId: "Ren" },
127
+ computerId: null,
128
+ role: "member" as const,
129
+ },
130
+ );
131
+ return { owner, member, calls, build, release };
132
+ }
133
+
134
+ describe("apps.build / apps.install", () => {
135
+ it("apps.build starts a job on the APPS binding and returns its id", async () => {
136
+ const f = fixture();
137
+ const result = await f.owner("apps.build", {
138
+ appId: "app_my-fork",
139
+ sourceRevision: `git:${"a".repeat(40)}`,
140
+ });
141
+ expect(result).toMatchObject({ jobId: "build_test", status: "running" });
142
+ expect(f.calls.build).toEqual({
143
+ appId: "app_my-fork",
144
+ sourceRevision: `git:${"a".repeat(40)}`,
145
+ });
146
+ });
147
+
148
+ it("apps.install switches the Release through the APPS binding", async () => {
149
+ const f = fixture();
150
+ const result = await f.owner("apps.install", {
151
+ installationId: "my-fork",
152
+ release: f.release,
153
+ });
154
+ expect(result).toEqual({ installationId: "my-fork", status: "enabled" });
155
+ expect(f.calls.select).toEqual({
156
+ installationId: "my-fork",
157
+ release: f.release,
158
+ });
159
+ });
160
+
161
+ it("apps.buildStatus reads a job's current status through the APPS binding", async () => {
162
+ const f = fixture();
163
+ const result = await f.owner("apps.buildStatus", { jobId: "build_test" });
164
+ expect(result).toMatchObject({ status: "succeeded", release: f.release });
165
+ expect(f.calls.buildStatus).toEqual({ jobId: "build_test" });
166
+ });
167
+
168
+ it("apps.buildStatus answers Pending while the job runs, so a Workflow step waits on it", async () => {
169
+ const f = fixture();
170
+ expect(appBackend.methods["apps.buildStatus"]?.policy.pending).toBe(true);
171
+ const result = await f.owner("apps.buildStatus", {
172
+ jobId: "build_running",
173
+ });
174
+ expect(result).toMatchObject({ kind: "pending" });
175
+ });
176
+
177
+ it("declares what the host's APPS operations require, so their grants reach the call", () => {
178
+ for (const [method, operation] of [
179
+ ["apps.build", "build"],
180
+ ["apps.buildStatus", "buildStatus"],
181
+ ["apps.install", "select"],
182
+ ["apps.fork", "fork"],
183
+ ] as const) {
184
+ const declared = appBackendBindingOperation("APPS", operation);
185
+ const policy = appBackend.methods[method]?.policy;
186
+ expect(declared).toBeDefined();
187
+ expect(policy).toBeDefined();
188
+ if (declared === undefined || policy === undefined) return;
189
+ // The host grants an operation only through approved capabilities;
190
+ // one that requires none is never granted at all.
191
+ expect(declared.requires.length).toBeGreaterThan(0);
192
+ expect(scopeOperationToMethod(declared, policy, "write")).toBe(true);
193
+ }
194
+ });
195
+
196
+ it("refuses apps.build for a non-owner member, typed", async () => {
197
+ const f = fixture();
198
+ await expect(
199
+ f.member("apps.build", {
200
+ appId: "app_my-fork",
201
+ sourceRevision: `git:${"a".repeat(40)}`,
202
+ }),
203
+ ).rejects.toSatisfy(
204
+ (error) =>
205
+ error instanceof AppMethodRefusal && error.code === "forbidden",
206
+ );
207
+ expect(f.calls.build).toBeUndefined();
208
+ });
209
+
210
+ it("refuses apps.install for a non-owner member, typed", async () => {
211
+ const f = fixture();
212
+ await expect(
213
+ f.member("apps.install", {
214
+ installationId: "my-fork",
215
+ release: f.release,
216
+ }),
217
+ ).rejects.toSatisfy(
218
+ (error) =>
219
+ error instanceof AppMethodRefusal && error.code === "forbidden",
220
+ );
221
+ expect(f.calls.select).toBeUndefined();
222
+ });
223
+ });
224
+
225
+ describe("apps.fork", () => {
226
+ const head = "c".repeat(40);
227
+ function forkFixture() {
228
+ const bonsai = bonsaiFixture({
229
+ info: (name) =>
230
+ Promise.resolve(
231
+ name === "my-app"
232
+ ? { name, defaultBranch: "main", lastPushAt: null, source: null }
233
+ : null,
234
+ ),
235
+ refs: () => Promise.resolve([{ name: "refs/heads/main", hash: head }]),
236
+ cloneUrl: (name) =>
237
+ Promise.resolve(`https://oyster.test/git/bonsai/${name}.git`),
238
+ });
239
+ const forks: unknown[] = [];
240
+ const refused = () => Promise.reject(new Error("Not part of a fork"));
241
+ const env = {
242
+ APPS: {
243
+ fork: (input: { platformAppId: string; name: string }) => {
244
+ forks.push(input);
245
+ return Promise.resolve({
246
+ kind: "forked" as const,
247
+ appId: "app_myapp",
248
+ repository: "my-app",
249
+ });
250
+ },
251
+ build: refused,
252
+ buildStatus: refused,
253
+ select: refused,
254
+ },
255
+ };
256
+ const call = (
257
+ role: "owner" | "member",
258
+ input: import("../shared/contract.js").JsonValue,
259
+ ) =>
260
+ appBackend.call(
261
+ "apps.fork",
262
+ input,
263
+ { bonsai, env },
264
+ {
265
+ actor: { kind: "member", memberId: "Levi" },
266
+ computerId: null,
267
+ role,
268
+ },
269
+ );
270
+ return { bonsai, forks, call };
271
+ }
272
+
273
+ it("records the host's fork as a Bonsai repository the owner merges in", async () => {
274
+ const f = forkFixture();
275
+ const result = await f.call("owner", {
276
+ appId: "app_bonsai",
277
+ name: "my-app",
278
+ });
279
+ expect(result).toEqual({ appId: "app_myapp", repository: "my-app" });
280
+ expect(f.forks).toEqual([{ platformAppId: "app_bonsai", name: "my-app" }]);
281
+ expect(f.bonsai.state.repo("my-app")).toMatchObject({
282
+ name: "my-app",
283
+ parent: null,
284
+ });
285
+ });
286
+
287
+ it("refuses a non-owner and an invalid name before any fork, typed", async () => {
288
+ const f = forkFixture();
289
+ await expect(
290
+ f.call("member", { appId: "app_bonsai", name: "my-app" }),
291
+ ).rejects.toSatisfy(
292
+ (error) =>
293
+ error instanceof AppMethodRefusal && error.code === "forbidden",
294
+ );
295
+ await expect(
296
+ f.call("owner", { appId: "app_bonsai", name: "my--app" }),
297
+ ).rejects.toSatisfy(
298
+ (error) => error instanceof AppMethodRefusal && error.code === "invalid",
299
+ );
300
+ expect(f.forks).toEqual([]);
301
+ });
302
+ });
@@ -0,0 +1,206 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { expect, it } from "vitest";
3
+ import { countingGit } from "@fungi.computer/agar/read-copy";
4
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
5
+ import {
6
+ backgroundContext,
7
+ backgroundOriginKey,
8
+ sealBackgroundOrigin,
9
+ admitClaimedOrigin,
10
+ } from "@fungi.computer/puffball/background-replay";
11
+ import { realGit } from "@fungi.computer/agar/test-kit/git";
12
+ import {
13
+ backgroundRuntime,
14
+ registerBackgroundProjector,
15
+ } from "../backend/background.js";
16
+ import type { WatchdogQueueTransition } from "@fungi.computer/watchdog";
17
+ import { saveFileEdit, projectQueuedFileEdit } from "../backend/file-edits.js";
18
+ import { gitStore } from "../backend/git.js";
19
+ import { BonsaiState, ensureBonsaiTables } from "../backend/state.js";
20
+
21
+ it("shares the actor runner and completes discarded file jobs without touching another member's lane or Git", async () => {
22
+ const database = new DatabaseSync(":memory:");
23
+ const owner = fromNodeSqlite(database);
24
+ ensureBonsaiTables(owner.sql);
25
+ const provider = realGit();
26
+ const controller = new AbortController();
27
+ const counted = countingGit(provider.git, {
28
+ limit: 100,
29
+ signal: controller.signal,
30
+ });
31
+ const state = new BonsaiState(owner.sql, owner.transactionSync);
32
+ state.registerRepo({
33
+ name: "pond",
34
+ description: null,
35
+ parent: null,
36
+ owner: null,
37
+ });
38
+ const bonsai = {
39
+ state,
40
+ store: gitStore(counted.git),
41
+ events: { emit: () => Promise.resolve() },
42
+ };
43
+ const key = await backgroundOriginKey("background-runtime-test-key");
44
+ const caller = async (memberId: string) =>
45
+ backgroundContext(
46
+ { actor: { kind: "member", memberId }, computerId: null },
47
+ [],
48
+ { name: "files.save", effect: "write" },
49
+ await sealBackgroundOrigin(key, {
50
+ instanceId: "runner-test",
51
+ lane: `member:${memberId}`,
52
+ method: "files.save",
53
+ }),
54
+ );
55
+ const member = await caller("owner");
56
+ const other = await caller("other");
57
+ const input = {
58
+ repo: "pond",
59
+ branch: "main",
60
+ path: "README.md",
61
+ text: "Saved\n",
62
+ baseCommit: "a".repeat(40),
63
+ baseBlob: null,
64
+ revision: null,
65
+ };
66
+ try {
67
+ const background = {
68
+ admit: (origin: NonNullable<typeof member.backgroundOrigin>) =>
69
+ admitClaimedOrigin(
70
+ key,
71
+ {
72
+ instanceId: "runner-test",
73
+ lane: "member:owner",
74
+ admitted: ["files.save"],
75
+ },
76
+ origin,
77
+ ),
78
+ };
79
+ const runtime = await backgroundRuntime(
80
+ bonsai,
81
+ member,
82
+ controller.signal,
83
+ background,
84
+ );
85
+ expect(
86
+ await backgroundRuntime(bonsai, member, controller.signal, background),
87
+ ).toBe(runtime);
88
+ const otherRuntime = await backgroundRuntime(bonsai, other);
89
+ const saved = await saveFileEdit(bonsai, member, input, runtime);
90
+ const otherSaved = await saveFileEdit(bonsai, other, input, otherRuntime);
91
+ expect(saved).toMatchObject({ kind: "saved", state: "pending" });
92
+ expect(otherSaved).toMatchObject({ kind: "saved", state: "pending" });
93
+ if (saved.kind !== "saved" || otherSaved.kind !== "saved")
94
+ throw new Error("Fixture rows not saved");
95
+ state.fileEdits.discard("member:owner", {
96
+ ...input,
97
+ revision: saved.revision,
98
+ });
99
+ expect(await runtime.tickConcurrent()).toMatchObject({
100
+ status: "settled",
101
+ job: { lane: "member:owner", outcome: "completed" },
102
+ });
103
+ expect(await runtime.tickConcurrent()).toEqual({ status: "no_ready" });
104
+ expect(state.fileEdits.pending("member:other", input)).toMatchObject({
105
+ revision: otherSaved.revision,
106
+ state: "pending",
107
+ });
108
+ expect(counted.total()).toBe(0);
109
+ } finally {
110
+ controller.abort();
111
+ database.close();
112
+ provider.close();
113
+ }
114
+ });
115
+
116
+ it("projects a retained missing-branch failure through the completed job receipt on an existing runner", async () => {
117
+ const database = new DatabaseSync(":memory:");
118
+ const owner = fromNodeSqlite(database);
119
+ ensureBonsaiTables(owner.sql);
120
+ const provider = realGit();
121
+ const state = new BonsaiState(owner.sql, owner.transactionSync);
122
+ const bonsai = {
123
+ state,
124
+ store: gitStore(provider.git),
125
+ events: { emit: () => Promise.resolve() },
126
+ };
127
+ const caller = backgroundContext(
128
+ { actor: { kind: "member", memberId: "owner" }, computerId: null },
129
+ [],
130
+ { name: "files.save", effect: "write" },
131
+ await sealBackgroundOrigin(
132
+ await backgroundOriginKey("background-runtime-test-key"),
133
+ { instanceId: "runner-test", lane: "member:owner", method: "files.save" },
134
+ ),
135
+ );
136
+ try {
137
+ const key = await backgroundOriginKey("background-runtime-test-key");
138
+ const background = {
139
+ admit: (origin: NonNullable<typeof caller.backgroundOrigin>) =>
140
+ admitClaimedOrigin(
141
+ key,
142
+ {
143
+ instanceId: "runner-test",
144
+ lane: "member:owner",
145
+ admitted: ["files.save"],
146
+ },
147
+ origin,
148
+ ),
149
+ };
150
+ const runtime = await backgroundRuntime(
151
+ bonsai,
152
+ caller,
153
+ new AbortController().signal,
154
+ background,
155
+ );
156
+ const transitions: WatchdogQueueTransition[] = [];
157
+ registerBackgroundProjector(bonsai, "file-edit", (transition) => {
158
+ projectQueuedFileEdit(bonsai, transition);
159
+ transitions.push(transition);
160
+ });
161
+ await provider.git.create("pond");
162
+ state.registerRepo({
163
+ name: "pond",
164
+ description: null,
165
+ parent: null,
166
+ owner: null,
167
+ });
168
+ await saveFileEdit(
169
+ bonsai,
170
+ caller,
171
+ {
172
+ repo: "pond",
173
+ branch: "removed",
174
+ path: "README.md",
175
+ text: "Retained\n",
176
+ baseCommit: "a".repeat(40),
177
+ baseBlob: null,
178
+ revision: null,
179
+ },
180
+ runtime,
181
+ );
182
+ expect(await runtime.tickConcurrent()).toMatchObject({
183
+ status: "settled",
184
+ job: { outcome: "completed" },
185
+ });
186
+ expect(
187
+ transitions.find(
188
+ (transition) => transition.settled?.outcome === "completed",
189
+ )?.settled,
190
+ ).toMatchObject({ outcome: "completed" });
191
+ expect(
192
+ state.fileEdits.pending("member:owner", {
193
+ repo: "pond",
194
+ branch: "removed",
195
+ path: "README.md",
196
+ }),
197
+ ).toMatchObject({
198
+ state: "failed",
199
+ failure: "branch_missing",
200
+ text: "Retained\n",
201
+ });
202
+ } finally {
203
+ database.close();
204
+ provider.close();
205
+ }
206
+ });
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import console from "node:console";
3
+ import { chromium } from "playwright";
4
+ const browser = await chromium.launch({ headless: true });
5
+ try {
6
+ const page = await browser.newPage({
7
+ viewport: { width: 1440, height: 1000 },
8
+ });
9
+ await page.goto("http://127.0.0.1:5188/test/browser/visual.html?board");
10
+ const below = page.locator('[data-tile="below"]');
11
+ await below.waitFor();
12
+ const initial = await below.boundingBox();
13
+ await page.getByRole("button", { name: "Expand document" }).click();
14
+ await page.waitForFunction(
15
+ (y) =>
16
+ globalThis.document
17
+ .querySelector('[data-tile="below"]')
18
+ .getBoundingClientRect().y >
19
+ y + 300,
20
+ initial.y,
21
+ );
22
+ const document = await page.locator('[data-tile="document"]').boundingBox();
23
+ assert.ok(
24
+ (await below.boundingBox()).y >= document.y + document.height,
25
+ "measured content pushes later rows without overlap",
26
+ );
27
+ await page.getByRole("button", { name: "Expand document" }).click();
28
+ await page.waitForFunction(
29
+ (y) =>
30
+ Math.abs(
31
+ globalThis.document
32
+ .querySelector('[data-tile="below"]')
33
+ .getBoundingClientRect().y - y,
34
+ ) < 1,
35
+ initial.y,
36
+ );
37
+ await page.setViewportSize({ width: 390, height: 1000 });
38
+ await page.waitForFunction(
39
+ () =>
40
+ globalThis.document
41
+ .querySelector('[data-tile="facts"]')
42
+ .getBoundingClientRect().y <
43
+ globalThis.document
44
+ .querySelector('[data-tile="document"]')
45
+ .getBoundingClientRect().y,
46
+ );
47
+ console.log(
48
+ "PASS board: measured rows grow and shrink; no overlap; explicit phone priority",
49
+ );
50
+ } finally {
51
+ await browser.close();
52
+ }