@fungi.computer/bonsai 0.0.0-stage → 0.1.0
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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +848 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/present.test.ts +27 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +47 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +719 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import type { EventSink } from "@fungi.computer/oyster/git";
|
|
2
|
+
import { createRecipientDeliveries } from "@fungi.computer/oyster/event-recipients";
|
|
3
|
+
import { bindingOf } from "./git-binding.fixture.js";
|
|
4
|
+
import { appGitPushFactSchema } from "@fungi.computer/agar/git";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import {
|
|
10
|
+
drainOutbox,
|
|
11
|
+
ensureBonsaiTables,
|
|
12
|
+
BonsaiState,
|
|
13
|
+
} from "../backend/state.js";
|
|
14
|
+
import { appBackend } from "../backend/methods.js";
|
|
15
|
+
import type { GitStore } from "../backend/git.js";
|
|
16
|
+
import { observePush } from "../backend/service.js";
|
|
17
|
+
import { hookEvent, hookSpec, type HookEvent } from "../shared/hooks.js";
|
|
18
|
+
|
|
19
|
+
const resultSchema = z.strictObject({ kind: z.literal("recorded") });
|
|
20
|
+
|
|
21
|
+
function fixture() {
|
|
22
|
+
const database = new DatabaseSync(":memory:");
|
|
23
|
+
const owner = fromNodeSqlite(database);
|
|
24
|
+
ensureBonsaiTables(owner.sql);
|
|
25
|
+
const reopen = () => new BonsaiState(owner.sql, owner.transactionSync);
|
|
26
|
+
const state = reopen();
|
|
27
|
+
const build = () => {
|
|
28
|
+
const activity = state.record({
|
|
29
|
+
at: 1000,
|
|
30
|
+
type: "repo.created",
|
|
31
|
+
repo: "notes",
|
|
32
|
+
actor: "Levi",
|
|
33
|
+
data: {},
|
|
34
|
+
});
|
|
35
|
+
const event = hookEvent.parse({
|
|
36
|
+
type: "repo.created",
|
|
37
|
+
timestamp: new Date(activity.at).toISOString(),
|
|
38
|
+
data: {
|
|
39
|
+
spec: hookSpec,
|
|
40
|
+
eventId: `evt_${String(activity.id)}`,
|
|
41
|
+
repository: {
|
|
42
|
+
name: "notes",
|
|
43
|
+
canonical: "notes",
|
|
44
|
+
cloneUrl: "https://git.test/notes.git",
|
|
45
|
+
},
|
|
46
|
+
actor: "Levi",
|
|
47
|
+
activity,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
result: resultSchema.parse({ kind: "recorded" }),
|
|
52
|
+
events: [event],
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
return { state, database, reopen, build };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const unused = () => Promise.reject(new Error("Unexpected Git operation"));
|
|
59
|
+
const repositoryInfo = (name: string) =>
|
|
60
|
+
Promise.resolve({
|
|
61
|
+
name,
|
|
62
|
+
defaultBranch: "main",
|
|
63
|
+
lastPushAt: null,
|
|
64
|
+
source: null,
|
|
65
|
+
});
|
|
66
|
+
const store: GitStore = {
|
|
67
|
+
get binding() {
|
|
68
|
+
return bindingOf(this);
|
|
69
|
+
},
|
|
70
|
+
cloneUrl: (repo) => Promise.resolve(`https://git.test/${repo}.git`),
|
|
71
|
+
create: unused,
|
|
72
|
+
fork: unused,
|
|
73
|
+
info: repositoryInfo,
|
|
74
|
+
log: () => Promise.resolve([]),
|
|
75
|
+
commit: unused,
|
|
76
|
+
tree: unused,
|
|
77
|
+
blob: unused,
|
|
78
|
+
refs: () => Promise.resolve([]),
|
|
79
|
+
exchange: unused,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
describe("Bonsai transactional receipts and outbox", () => {
|
|
83
|
+
it("reads committed activity without emitting or acknowledging pending events", async () => {
|
|
84
|
+
const { state, database, build } = fixture();
|
|
85
|
+
try {
|
|
86
|
+
state.transition("create:one", "request-one", resultSchema, build);
|
|
87
|
+
const emit = vi.fn(() => Promise.resolve());
|
|
88
|
+
database.exec(
|
|
89
|
+
"CREATE TRIGGER refuse_read_ack BEFORE UPDATE ON outbox BEGIN SELECT RAISE(ABORT,'read wrote outbox'); END",
|
|
90
|
+
);
|
|
91
|
+
await appBackend.call(
|
|
92
|
+
"activity.list",
|
|
93
|
+
{},
|
|
94
|
+
{ bonsai: { state, store, events: { emit } } },
|
|
95
|
+
{ computerId: null, actor: { kind: "member", memberId: "Levi" } },
|
|
96
|
+
);
|
|
97
|
+
expect(emit).not.toHaveBeenCalled();
|
|
98
|
+
expect(state.pendingEvents()).toHaveLength(1);
|
|
99
|
+
} finally {
|
|
100
|
+
database.close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps a committed write successful when delivery fails and retries on the next write", async () => {
|
|
105
|
+
const { state, database } = fixture();
|
|
106
|
+
try {
|
|
107
|
+
const delivered: string[] = [];
|
|
108
|
+
const failure = new Error("Event sink unavailable");
|
|
109
|
+
const emit = vi.fn<EventSink["emit"]>((_type, _payload, eventId) => {
|
|
110
|
+
expect(state.repo("notes")).not.toBeNull();
|
|
111
|
+
delivered.push(eventId);
|
|
112
|
+
return delivered.length === 1
|
|
113
|
+
? Promise.reject(failure)
|
|
114
|
+
: Promise.resolve();
|
|
115
|
+
});
|
|
116
|
+
const bonsai = {
|
|
117
|
+
state,
|
|
118
|
+
store: { ...store, create: repositoryInfo },
|
|
119
|
+
events: { emit },
|
|
120
|
+
};
|
|
121
|
+
const caller = {
|
|
122
|
+
computerId: null,
|
|
123
|
+
actor: { kind: "member", memberId: "Levi" },
|
|
124
|
+
} as const;
|
|
125
|
+
await expect(
|
|
126
|
+
appBackend.call("repos.create", { name: "notes" }, { bonsai }, caller),
|
|
127
|
+
).resolves.toMatchObject({ name: "notes" });
|
|
128
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(state.pendingEvents()).toHaveLength(1);
|
|
130
|
+
await appBackend.call("repos.list", {}, { bonsai }, caller);
|
|
131
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
132
|
+
const tiles = { view: "overview", placements: [] };
|
|
133
|
+
await expect(
|
|
134
|
+
appBackend.call(
|
|
135
|
+
"insights.layout.save",
|
|
136
|
+
{ repo: "notes", tiles },
|
|
137
|
+
{ bonsai },
|
|
138
|
+
caller,
|
|
139
|
+
),
|
|
140
|
+
).resolves.toEqual({ tiles });
|
|
141
|
+
expect(delivered).toHaveLength(2);
|
|
142
|
+
expect(delivered[1]).toBe(delivered[0]);
|
|
143
|
+
expect(state.pendingEvents()).toHaveLength(0);
|
|
144
|
+
} finally {
|
|
145
|
+
database.close();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("commits a write when the subscribing Workflows instance is down, and the host retries that delivery", async () => {
|
|
150
|
+
const { state, database } = fixture();
|
|
151
|
+
const host = new DatabaseSync(":memory:");
|
|
152
|
+
try {
|
|
153
|
+
let clock = 1_000;
|
|
154
|
+
let workflowsDown = true;
|
|
155
|
+
const attempts: string[] = [];
|
|
156
|
+
const deliveries = createRecipientDeliveries({
|
|
157
|
+
sql: fromNodeSqlite(host).sql,
|
|
158
|
+
now: () => clock,
|
|
159
|
+
wake: () => Promise.resolve(),
|
|
160
|
+
deliver: (recipient, event) => {
|
|
161
|
+
attempts.push(`${recipient}:${event.type}`);
|
|
162
|
+
return workflowsDown
|
|
163
|
+
? Promise.reject(new Error("App instance returned 502"))
|
|
164
|
+
: Promise.resolve();
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
// The host's EVENTS.emit: accept durably for each subscriber, deliver later.
|
|
168
|
+
const emit: EventSink["emit"] = (type, payload, eventId) => {
|
|
169
|
+
deliveries.enqueue(
|
|
170
|
+
{
|
|
171
|
+
source: {
|
|
172
|
+
installationId: "bonsai",
|
|
173
|
+
appId: "app_bonsai",
|
|
174
|
+
releaseId: "rel_bonsai",
|
|
175
|
+
},
|
|
176
|
+
type,
|
|
177
|
+
eventId,
|
|
178
|
+
payload,
|
|
179
|
+
},
|
|
180
|
+
["workflows-library"],
|
|
181
|
+
);
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
};
|
|
184
|
+
const bonsai = {
|
|
185
|
+
state,
|
|
186
|
+
store: { ...store, create: repositoryInfo },
|
|
187
|
+
events: { emit },
|
|
188
|
+
};
|
|
189
|
+
await expect(
|
|
190
|
+
appBackend.call(
|
|
191
|
+
"repos.create",
|
|
192
|
+
{ name: "reed-pond" },
|
|
193
|
+
{ bonsai },
|
|
194
|
+
{ computerId: null, actor: { kind: "member", memberId: "Levi" } },
|
|
195
|
+
),
|
|
196
|
+
).resolves.toMatchObject({ name: "reed-pond" });
|
|
197
|
+
expect(state.repo("reed-pond")).not.toBeNull();
|
|
198
|
+
expect(state.pendingEvents()).toHaveLength(0);
|
|
199
|
+
expect(attempts).toEqual([]);
|
|
200
|
+
|
|
201
|
+
await deliveries.drain();
|
|
202
|
+
expect(deliveries.deliveries("workflows-library")).toMatchObject([
|
|
203
|
+
{
|
|
204
|
+
type: "repo.created",
|
|
205
|
+
state: "pending",
|
|
206
|
+
attempts: 1,
|
|
207
|
+
lastError: "Error: App instance returned 502",
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
workflowsDown = false;
|
|
212
|
+
clock = deliveries.nextDue() ?? clock;
|
|
213
|
+
await deliveries.drain();
|
|
214
|
+
expect(deliveries.deliveries("workflows-library")).toMatchObject([
|
|
215
|
+
{
|
|
216
|
+
type: "repo.created",
|
|
217
|
+
state: "delivered",
|
|
218
|
+
attempts: 2,
|
|
219
|
+
lastError: null,
|
|
220
|
+
},
|
|
221
|
+
]);
|
|
222
|
+
expect(attempts).toEqual([
|
|
223
|
+
"workflows-library:repo.created",
|
|
224
|
+
"workflows-library:repo.created",
|
|
225
|
+
]);
|
|
226
|
+
} finally {
|
|
227
|
+
host.close();
|
|
228
|
+
database.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("recovers a committed event after restart and lost acknowledgement without repeating activity", async () => {
|
|
233
|
+
const { state, database, reopen, build } = fixture();
|
|
234
|
+
try {
|
|
235
|
+
expect(
|
|
236
|
+
state.transition("create:one", "request-one", resultSchema, build),
|
|
237
|
+
).toEqual({ kind: "recorded" });
|
|
238
|
+
const delivered: HookEvent[] = [];
|
|
239
|
+
await expect(
|
|
240
|
+
drainOutbox({
|
|
241
|
+
state,
|
|
242
|
+
events: {
|
|
243
|
+
emit: (_type, payload) => {
|
|
244
|
+
delivered.push(hookEvent.parse(payload));
|
|
245
|
+
return Promise.reject(new Error("Acknowledgement lost"));
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
).resolves.toMatchObject({ kind: "deferred", delivered: 0 });
|
|
250
|
+
const reopened = reopen();
|
|
251
|
+
expect(
|
|
252
|
+
reopened.transition("create:one", "request-one", resultSchema, () => {
|
|
253
|
+
throw new Error("Replay must not rebuild");
|
|
254
|
+
}),
|
|
255
|
+
).toEqual({ kind: "recorded" });
|
|
256
|
+
await drainOutbox({
|
|
257
|
+
state: reopened,
|
|
258
|
+
events: {
|
|
259
|
+
emit: (_type, payload) => {
|
|
260
|
+
delivered.push(hookEvent.parse(payload));
|
|
261
|
+
return Promise.resolve();
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
expect(delivered).toHaveLength(2);
|
|
266
|
+
expect(delivered[1]).toEqual(delivered[0]);
|
|
267
|
+
expect(reopened.events(null, 20)).toHaveLength(1);
|
|
268
|
+
await expect(
|
|
269
|
+
drainOutbox({
|
|
270
|
+
state: reopened,
|
|
271
|
+
events: {
|
|
272
|
+
emit: () => Promise.reject(new Error("Already acknowledged")),
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
).resolves.toEqual({ kind: "delivered", delivered: 0 });
|
|
276
|
+
} finally {
|
|
277
|
+
database.close();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("retries a failed host push on the next write without repeating Git preparation", async () => {
|
|
282
|
+
const { state, database } = fixture();
|
|
283
|
+
try {
|
|
284
|
+
state.registerRepo({
|
|
285
|
+
name: "notes",
|
|
286
|
+
description: null,
|
|
287
|
+
parent: null,
|
|
288
|
+
owner: null,
|
|
289
|
+
});
|
|
290
|
+
const delivered: HookEvent[] = [];
|
|
291
|
+
let failed = false;
|
|
292
|
+
const bonsai = {
|
|
293
|
+
state,
|
|
294
|
+
store,
|
|
295
|
+
events: {
|
|
296
|
+
emit: (_type: string, payload: unknown) => {
|
|
297
|
+
delivered.push(hookEvent.parse(payload));
|
|
298
|
+
if (!failed) {
|
|
299
|
+
failed = true;
|
|
300
|
+
return Promise.reject(new Error("Sink unavailable"));
|
|
301
|
+
}
|
|
302
|
+
return Promise.resolve();
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
const fact = appGitPushFactSchema.parse({
|
|
307
|
+
repository: "notes",
|
|
308
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
309
|
+
eventId: "push_one",
|
|
310
|
+
updates: [
|
|
311
|
+
{
|
|
312
|
+
ref: "refs/heads/main",
|
|
313
|
+
before: "a".repeat(40),
|
|
314
|
+
after: "0".repeat(40),
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
});
|
|
318
|
+
await expect(observePush(bonsai, fact)).resolves.toBeUndefined();
|
|
319
|
+
await appBackend.call(
|
|
320
|
+
"activity.list",
|
|
321
|
+
{},
|
|
322
|
+
{ bonsai },
|
|
323
|
+
{ computerId: null, actor: { kind: "member", memberId: "Levi" } },
|
|
324
|
+
);
|
|
325
|
+
expect(delivered).toHaveLength(1);
|
|
326
|
+
await appBackend.call(
|
|
327
|
+
"insights.layout.save",
|
|
328
|
+
{ repo: "notes", tiles: { view: "overview", placements: [] } },
|
|
329
|
+
{ bonsai },
|
|
330
|
+
{ computerId: null, actor: { kind: "member", memberId: "Levi" } },
|
|
331
|
+
);
|
|
332
|
+
expect(delivered).toHaveLength(2);
|
|
333
|
+
expect(delivered[1]).toEqual(delivered[0]);
|
|
334
|
+
expect(state.events(null, 20)).toHaveLength(1);
|
|
335
|
+
await observePush(
|
|
336
|
+
{ ...bonsai, store: { ...store, cloneUrl: unused } },
|
|
337
|
+
fact,
|
|
338
|
+
);
|
|
339
|
+
expect(delivered).toHaveLength(2);
|
|
340
|
+
} finally {
|
|
341
|
+
database.close();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("refuses a reused key with different input without writing another activity", () => {
|
|
346
|
+
const { state, database, build } = fixture();
|
|
347
|
+
try {
|
|
348
|
+
state.transition("create:one", "request-one", resultSchema, build);
|
|
349
|
+
expect(() =>
|
|
350
|
+
state.transition("create:one", "request-two", resultSchema, build),
|
|
351
|
+
).toThrow("different");
|
|
352
|
+
expect(state.events(null, 20)).toHaveLength(1);
|
|
353
|
+
} finally {
|
|
354
|
+
database.close();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rolls back domain activity and receipt if its outbox insert cannot commit", () => {
|
|
359
|
+
const { state, database, build } = fixture();
|
|
360
|
+
try {
|
|
361
|
+
database.exec(
|
|
362
|
+
"CREATE TRIGGER refuse_outbox BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT,'outbox unavailable'); END",
|
|
363
|
+
);
|
|
364
|
+
expect(() =>
|
|
365
|
+
state.transition("create:one", "request-one", resultSchema, build),
|
|
366
|
+
).toThrow("outbox unavailable");
|
|
367
|
+
expect(state.events(null, 20)).toEqual([]);
|
|
368
|
+
database.exec("DROP TRIGGER refuse_outbox");
|
|
369
|
+
expect(
|
|
370
|
+
state.transition("create:one", "request-one", resultSchema, build),
|
|
371
|
+
).toEqual({ kind: "recorded" });
|
|
372
|
+
expect(state.events(null, 20)).toHaveLength(1);
|
|
373
|
+
} finally {
|
|
374
|
+
database.close();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("keeps a committed write bounded to 32 emits without capping event acceptance", async () => {
|
|
379
|
+
const { state, database, build } = fixture();
|
|
380
|
+
try {
|
|
381
|
+
state.transition("push:many", "request-many", resultSchema, () => ({
|
|
382
|
+
result: resultSchema.parse({ kind: "recorded" }),
|
|
383
|
+
events: Array.from({ length: 65 }, () => build().events[0]).map(
|
|
384
|
+
(event) => hookEvent.parse(event),
|
|
385
|
+
),
|
|
386
|
+
}));
|
|
387
|
+
const ids: string[] = [];
|
|
388
|
+
const emit: EventSink["emit"] = (_type, _payload, eventId) => {
|
|
389
|
+
ids.push(eventId);
|
|
390
|
+
return Promise.resolve();
|
|
391
|
+
};
|
|
392
|
+
const bonsai = {
|
|
393
|
+
state,
|
|
394
|
+
store: { ...store, create: repositoryInfo },
|
|
395
|
+
events: { emit },
|
|
396
|
+
};
|
|
397
|
+
const caller = {
|
|
398
|
+
computerId: null,
|
|
399
|
+
actor: { kind: "member", memberId: "Levi" },
|
|
400
|
+
} as const;
|
|
401
|
+
await appBackend.call(
|
|
402
|
+
"repos.create",
|
|
403
|
+
{ name: "notes" },
|
|
404
|
+
{ bonsai },
|
|
405
|
+
caller,
|
|
406
|
+
);
|
|
407
|
+
expect(ids).toHaveLength(32);
|
|
408
|
+
expect(state.events(null, 100)).toHaveLength(66);
|
|
409
|
+
const input = {
|
|
410
|
+
repo: "notes",
|
|
411
|
+
tiles: { view: "overview", placements: [] },
|
|
412
|
+
};
|
|
413
|
+
await appBackend.call("insights.layout.save", input, { bonsai }, caller);
|
|
414
|
+
expect(ids).toHaveLength(64);
|
|
415
|
+
await appBackend.call("insights.layout.save", input, { bonsai }, caller);
|
|
416
|
+
expect(ids).toHaveLength(66);
|
|
417
|
+
expect(new Set(ids).size).toBe(66);
|
|
418
|
+
await appBackend.call("insights.layout.save", input, { bonsai }, caller);
|
|
419
|
+
expect(ids).toHaveLength(66);
|
|
420
|
+
} finally {
|
|
421
|
+
database.close();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import type { Agent } from "../shared/contract.js";
|
|
5
|
+
import {
|
|
6
|
+
AgentName,
|
|
7
|
+
Sparkline,
|
|
8
|
+
actorLabel,
|
|
9
|
+
agentColors,
|
|
10
|
+
type AgentColors,
|
|
11
|
+
} from "../web/parts.js";
|
|
12
|
+
import { shortActor } from "../web/format.js";
|
|
13
|
+
|
|
14
|
+
const agent = (overrides: Partial<Agent>): Agent => ({
|
|
15
|
+
id: "morel",
|
|
16
|
+
name: "morel",
|
|
17
|
+
color: "c0",
|
|
18
|
+
kind: "agent",
|
|
19
|
+
createdAt: 1,
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("actorLabel", () => {
|
|
24
|
+
it("keeps a resolved agent's recorded name", () => {
|
|
25
|
+
const colors: AgentColors = agentColors([agent({})]);
|
|
26
|
+
expect(actorLabel("morel", colors)).toBe("morel");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("replaces a resolved human member's raw host id with a short label", () => {
|
|
30
|
+
const id = "tenant_member_5mirwh5a0r0p7v793qxhy";
|
|
31
|
+
const colors: AgentColors = agentColors([
|
|
32
|
+
agent({ id, name: id, kind: "human" }),
|
|
33
|
+
]);
|
|
34
|
+
const label = actorLabel(id, colors);
|
|
35
|
+
expect(label).toBe(shortActor(id));
|
|
36
|
+
expect(label).not.toContain(id);
|
|
37
|
+
expect(label).not.toBe(id);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("leaves an unresolved actor's string untouched rather than guessing", () => {
|
|
41
|
+
expect(actorLabel("unresolved-actor", new Map())).toBe("unresolved-actor");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("AgentName", () => {
|
|
46
|
+
it("never renders a human actor's raw member id", () => {
|
|
47
|
+
const id = "tenant_member_5mirwh5a0r0p7v793qxhy";
|
|
48
|
+
const colors: AgentColors = agentColors([
|
|
49
|
+
agent({ id, name: id, kind: "human" }),
|
|
50
|
+
]);
|
|
51
|
+
const html = renderToStaticMarkup(
|
|
52
|
+
createElement(AgentName, { name: id, colors }),
|
|
53
|
+
);
|
|
54
|
+
expect(html).not.toContain(id);
|
|
55
|
+
expect(html).toContain("Member");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("still renders a bot agent's readable name", () => {
|
|
59
|
+
const colors: AgentColors = agentColors([agent({})]);
|
|
60
|
+
const html = renderToStaticMarkup(
|
|
61
|
+
createElement(AgentName, { name: "morel", colors }),
|
|
62
|
+
);
|
|
63
|
+
expect(html).toContain("morel");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("Sparkline", () => {
|
|
68
|
+
it("renders nothing when there is no activity", () => {
|
|
69
|
+
const values = Array.from({ length: 24 }, () => 0);
|
|
70
|
+
expect(renderToStaticMarkup(createElement(Sparkline, { values }))).toBe("");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders nothing for a single stray data point", () => {
|
|
74
|
+
const values = Array.from({ length: 24 }, () => 0);
|
|
75
|
+
values[20] = 1;
|
|
76
|
+
expect(renderToStaticMarkup(createElement(Sparkline, { values }))).toBe("");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("renders a sparkline once there is a real multi-point trend", () => {
|
|
80
|
+
const values = Array.from({ length: 24 }, () => 0);
|
|
81
|
+
values[2] = 1;
|
|
82
|
+
values[10] = 3;
|
|
83
|
+
const html = renderToStaticMarkup(createElement(Sparkline, { values }));
|
|
84
|
+
expect(html).toContain("bonsai-spark");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { acceptedRefs, parseReceivePack } from "@fungi.computer/agar/git-wire";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
function pkt(text: string): string {
|
|
7
|
+
return (text.length + 4).toString(16).padStart(4, "0") + text;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function bytes(...parts: string[]): Uint8Array {
|
|
11
|
+
return encoder.encode(parts.join(""));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const a = "a".repeat(40);
|
|
15
|
+
const b = "b".repeat(40);
|
|
16
|
+
const zero = "0".repeat(40);
|
|
17
|
+
|
|
18
|
+
describe("push observation", () => {
|
|
19
|
+
it("reads ref commands and capabilities before the pack", () => {
|
|
20
|
+
const body = bytes(
|
|
21
|
+
pkt(`${zero} ${a} refs/heads/main\0report-status side-band-64k\n`),
|
|
22
|
+
pkt(`${a} ${b} refs/heads/feature\n`),
|
|
23
|
+
"0000",
|
|
24
|
+
"PACK....",
|
|
25
|
+
);
|
|
26
|
+
expect(parseReceivePack(body)).toEqual({
|
|
27
|
+
kind: "ok",
|
|
28
|
+
updates: [
|
|
29
|
+
{ before: zero, after: a, ref: "refs/heads/main" },
|
|
30
|
+
{ before: a, after: b, ref: "refs/heads/feature" },
|
|
31
|
+
],
|
|
32
|
+
capabilities: ["report-status", "side-band-64k"],
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads accepted refs from side-band report-status", () => {
|
|
37
|
+
const inner =
|
|
38
|
+
pkt("unpack ok\n") +
|
|
39
|
+
pkt("ok refs/heads/main\n") +
|
|
40
|
+
pkt("ng refs/heads/x denied\n") +
|
|
41
|
+
"0000";
|
|
42
|
+
const response = bytes(pkt(`\u0001${inner}`), "0000");
|
|
43
|
+
expect([...acceptedRefs(response, true)]).toEqual(["refs/heads/main"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reads accepted refs without side-band", () => {
|
|
47
|
+
const response = bytes(
|
|
48
|
+
pkt("unpack ok\n"),
|
|
49
|
+
pkt("ok refs/heads/main\n"),
|
|
50
|
+
"0000",
|
|
51
|
+
);
|
|
52
|
+
expect([...acceptedRefs(response, false)]).toEqual(["refs/heads/main"]);
|
|
53
|
+
});
|
|
54
|
+
});
|