@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.
- package/CHANGELOG.md +17 -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 +835 -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/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 +5 -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 +715 -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,250 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import {
|
|
4
|
+
hookSpec,
|
|
5
|
+
signHook,
|
|
6
|
+
signStandardWebhook,
|
|
7
|
+
verifyHook,
|
|
8
|
+
type HookEvent,
|
|
9
|
+
type HookEventType,
|
|
10
|
+
} from "../shared/hooks.js";
|
|
11
|
+
import { buildAppDeclaration } from "@fungi.computer/agar/backend-declaration";
|
|
12
|
+
import { appBackend as bonsaiBackend } from "../backend/methods.js";
|
|
13
|
+
import bonsaiMetadata from "../package.json" with { type: "json" };
|
|
14
|
+
const appDeclaration = buildAppDeclaration(
|
|
15
|
+
bonsaiBackend,
|
|
16
|
+
bonsaiMetadata.fungi.app,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const event: HookEvent = {
|
|
20
|
+
type: "git.push",
|
|
21
|
+
timestamp: "2026-10-10T20:00:00.000Z",
|
|
22
|
+
data: {
|
|
23
|
+
spec: hookSpec,
|
|
24
|
+
eventId: "evt_7",
|
|
25
|
+
repository: {
|
|
26
|
+
name: "pond--ada",
|
|
27
|
+
canonical: "pond",
|
|
28
|
+
cloneUrl: "https://bonsai.example/git/pond--ada.git",
|
|
29
|
+
},
|
|
30
|
+
actor: "ada",
|
|
31
|
+
activity: {
|
|
32
|
+
id: 7,
|
|
33
|
+
at: 1,
|
|
34
|
+
type: "push",
|
|
35
|
+
repo: "pond--ada",
|
|
36
|
+
actor: "ada",
|
|
37
|
+
data: {
|
|
38
|
+
updates: [
|
|
39
|
+
{
|
|
40
|
+
ref: "refs/heads/main",
|
|
41
|
+
before: "a".repeat(40),
|
|
42
|
+
after: "b".repeat(40),
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
commits: [{ hash: "b".repeat(40), message: "Add food" }],
|
|
46
|
+
paths: ["src/food.ts"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("hook signatures", () => {
|
|
53
|
+
it("verifies what it signs", async () => {
|
|
54
|
+
const body = JSON.stringify(event);
|
|
55
|
+
const header = await signHook("whsec_test", body, 1000);
|
|
56
|
+
expect(await verifyHook("whsec_test", body, header, 1010)).toEqual({
|
|
57
|
+
ok: true,
|
|
58
|
+
event,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("refuses a wrong secret, a changed body and a stale timestamp", async () => {
|
|
63
|
+
const body = JSON.stringify(event);
|
|
64
|
+
const header = await signHook("whsec_test", body, 1000);
|
|
65
|
+
expect(await verifyHook("whsec_other", body, header, 1000)).toEqual({
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "signature",
|
|
68
|
+
});
|
|
69
|
+
expect(await verifyHook("whsec_test", `${body} `, header, 1000)).toEqual({
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: "signature",
|
|
72
|
+
});
|
|
73
|
+
expect(await verifyHook("whsec_test", body, header, 2000)).toEqual({
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "stale",
|
|
76
|
+
});
|
|
77
|
+
expect(await verifyHook("whsec_test", body, null, 1000)).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "signature",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("Standard Webhooks signing", () => {
|
|
85
|
+
it("matches the specification's test vector", async () => {
|
|
86
|
+
expect(
|
|
87
|
+
await signStandardWebhook(
|
|
88
|
+
"whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
|
|
89
|
+
"msg_p5jXN8AQM9LWM0D4loKWxJek",
|
|
90
|
+
1614265330,
|
|
91
|
+
'{"test": 2432232314}',
|
|
92
|
+
),
|
|
93
|
+
).toBe("v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const identity = {
|
|
98
|
+
repo: "pond",
|
|
99
|
+
number: 1,
|
|
100
|
+
sourceRepo: "pond--ada",
|
|
101
|
+
sourceRef: "main",
|
|
102
|
+
targetRef: "main",
|
|
103
|
+
head: "b".repeat(40),
|
|
104
|
+
};
|
|
105
|
+
const declaredActivities = {
|
|
106
|
+
"git.push": event.data.activity,
|
|
107
|
+
"git.fork": {
|
|
108
|
+
...event.data.activity,
|
|
109
|
+
type: "fork",
|
|
110
|
+
data: { fork: "pond--ada" },
|
|
111
|
+
},
|
|
112
|
+
"repo.created": { ...event.data.activity, type: "repo.created", data: {} },
|
|
113
|
+
"change.opened": {
|
|
114
|
+
...event.data.activity,
|
|
115
|
+
type: "change.opened",
|
|
116
|
+
data: { change: identity },
|
|
117
|
+
},
|
|
118
|
+
"change.updated": {
|
|
119
|
+
...event.data.activity,
|
|
120
|
+
type: "change.updated",
|
|
121
|
+
data: { change: identity },
|
|
122
|
+
},
|
|
123
|
+
"change.approved": {
|
|
124
|
+
...event.data.activity,
|
|
125
|
+
type: "change.approved",
|
|
126
|
+
data: { change: identity },
|
|
127
|
+
},
|
|
128
|
+
"change.checks_requested": {
|
|
129
|
+
...event.data.activity,
|
|
130
|
+
type: "change.checks_requested",
|
|
131
|
+
data: { change: identity },
|
|
132
|
+
},
|
|
133
|
+
"change.checked": {
|
|
134
|
+
...event.data.activity,
|
|
135
|
+
type: "change.checked",
|
|
136
|
+
data: { change: identity },
|
|
137
|
+
},
|
|
138
|
+
"change.closed": {
|
|
139
|
+
...event.data.activity,
|
|
140
|
+
type: "change.closed",
|
|
141
|
+
data: { change: identity },
|
|
142
|
+
},
|
|
143
|
+
"change.merged": {
|
|
144
|
+
...event.data.activity,
|
|
145
|
+
type: "merge",
|
|
146
|
+
data: {
|
|
147
|
+
change: identity,
|
|
148
|
+
from: "pond--ada",
|
|
149
|
+
ref: "main",
|
|
150
|
+
before: "a".repeat(40),
|
|
151
|
+
after: "b".repeat(40),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
"change.conflicted": {
|
|
155
|
+
...event.data.activity,
|
|
156
|
+
type: "merge.conflict",
|
|
157
|
+
data: {
|
|
158
|
+
change: identity,
|
|
159
|
+
from: "pond--ada",
|
|
160
|
+
ref: "main",
|
|
161
|
+
paths: ["src/food.ts"],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
} satisfies Record<HookEventType, HookEvent["data"]["activity"]>;
|
|
165
|
+
|
|
166
|
+
describe("declared hook payloads", () => {
|
|
167
|
+
it("declares the capability needed to emit Git and stored Change hooks", () => {
|
|
168
|
+
expect(appDeclaration.capabilities).toContain("app.events.emit");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("declares the Git and stored Change hook event names", () => {
|
|
172
|
+
expect(Object.keys(appDeclaration.api?.events ?? {}).sort()).toEqual([
|
|
173
|
+
"change.approved",
|
|
174
|
+
"change.checked",
|
|
175
|
+
"change.checks_requested",
|
|
176
|
+
"change.closed",
|
|
177
|
+
"change.conflicted",
|
|
178
|
+
"change.merged",
|
|
179
|
+
"change.opened",
|
|
180
|
+
"change.updated",
|
|
181
|
+
"git.fork",
|
|
182
|
+
"git.push",
|
|
183
|
+
"repo.created",
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it.each(Object.entries(declaredActivities))(
|
|
188
|
+
"declares the full %s envelope with its own event type",
|
|
189
|
+
(type, activity) => {
|
|
190
|
+
const declared = appDeclaration.api?.events?.[type];
|
|
191
|
+
expect(declared).toBeDefined();
|
|
192
|
+
if (!declared) throw new Error(`${type} is undeclared`);
|
|
193
|
+
const payload = z.fromJSONSchema({ ...declared.payload });
|
|
194
|
+
const data = type.startsWith("change.")
|
|
195
|
+
? { ...event.data, activity, change: identity }
|
|
196
|
+
: { ...event.data, activity };
|
|
197
|
+
const emitted = { ...event, type, data };
|
|
198
|
+
expect(payload.parse(emitted)).toEqual(emitted);
|
|
199
|
+
expect(payload.safeParse(emitted.data).success).toBe(false);
|
|
200
|
+
expect(
|
|
201
|
+
payload.safeParse({
|
|
202
|
+
...emitted,
|
|
203
|
+
type: type === "git.push" ? "git.fork" : "git.push",
|
|
204
|
+
}).success,
|
|
205
|
+
).toBe(false);
|
|
206
|
+
expect(payload.safeParse({ ...emitted, extra: true }).success).toBe(
|
|
207
|
+
false,
|
|
208
|
+
);
|
|
209
|
+
expect(
|
|
210
|
+
payload.safeParse({
|
|
211
|
+
...emitted,
|
|
212
|
+
data: { ...emitted.data, spec: "unknown" },
|
|
213
|
+
}).success,
|
|
214
|
+
).toBe(false);
|
|
215
|
+
expect(
|
|
216
|
+
payload.safeParse({
|
|
217
|
+
...emitted,
|
|
218
|
+
data: {
|
|
219
|
+
...emitted.data,
|
|
220
|
+
activity: { ...activity, data: { unexpected: true } },
|
|
221
|
+
},
|
|
222
|
+
}).success,
|
|
223
|
+
).toBe(false);
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("signs event permission on every outbox-producing write and none of the reads", () => {
|
|
229
|
+
const methods = appDeclaration.api?.methods;
|
|
230
|
+
if (methods === undefined) throw new Error("Bonsai API missing");
|
|
231
|
+
expect(methods["insights.layout.save"]?.requires).toEqual([
|
|
232
|
+
"app.events.emit",
|
|
233
|
+
]);
|
|
234
|
+
expect(methods["changes.merge"]?.requires).toEqual([
|
|
235
|
+
"git.repository",
|
|
236
|
+
"app.events.emit",
|
|
237
|
+
]);
|
|
238
|
+
const writes = Object.values(methods).filter(
|
|
239
|
+
(method) => method.effect === "write",
|
|
240
|
+
);
|
|
241
|
+
const reads = Object.values(methods).filter(
|
|
242
|
+
(method) => method.effect === "read",
|
|
243
|
+
);
|
|
244
|
+
expect(writes.length).toBeGreaterThan(0);
|
|
245
|
+
expect(reads.length).toBeGreaterThan(0);
|
|
246
|
+
for (const method of writes)
|
|
247
|
+
expect(method.requires).toContain("app.events.emit");
|
|
248
|
+
for (const method of reads)
|
|
249
|
+
expect(method.requires).not.toContain("app.events.emit");
|
|
250
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
actorSplit,
|
|
4
|
+
contributionDays,
|
|
5
|
+
contributionWeeks,
|
|
6
|
+
} from "../web/insight-data.js";
|
|
7
|
+
import type { Agent, Graph } from "../shared/contract.js";
|
|
8
|
+
const actors: Agent[] = [
|
|
9
|
+
{ id: "human", name: "Human", kind: "human", color: "c0", createdAt: 0 },
|
|
10
|
+
{ id: "agent", name: "Agent", kind: "agent", color: "c1", createdAt: 0 },
|
|
11
|
+
];
|
|
12
|
+
const now = Date.UTC(2026, 9, 12);
|
|
13
|
+
const graph: Graph = {
|
|
14
|
+
lanes: [],
|
|
15
|
+
nodes: ["human", "agent", null].map((agent, index) => ({
|
|
16
|
+
hash: String(index),
|
|
17
|
+
parents: index === 1 ? ["parent1", "parent2"] : [],
|
|
18
|
+
lanes: [],
|
|
19
|
+
message: "test",
|
|
20
|
+
author: "Human",
|
|
21
|
+
agent,
|
|
22
|
+
origin: null,
|
|
23
|
+
committedAt: now / 1000,
|
|
24
|
+
})),
|
|
25
|
+
};
|
|
26
|
+
it("classifies by recorded actor kind, never by display name or a missing origin", () => {
|
|
27
|
+
expect(actorSplit(graph, actors).commits).toEqual({
|
|
28
|
+
human: 1,
|
|
29
|
+
agent: 1,
|
|
30
|
+
unknown: 1,
|
|
31
|
+
});
|
|
32
|
+
expect(actorSplit(graph, actors).merges).toEqual({
|
|
33
|
+
human: 0,
|
|
34
|
+
agent: 1,
|
|
35
|
+
unknown: 0,
|
|
36
|
+
});
|
|
37
|
+
expect(
|
|
38
|
+
contributionWeeks(graph, actors, now).contributors.map((item) => item.kind),
|
|
39
|
+
).toEqual(["human", "agent", "unknown"]);
|
|
40
|
+
});
|
|
41
|
+
it("counts unique graph commits into UTC days and keeps out-of-window commits out", () => {
|
|
42
|
+
const old = graph.nodes[0];
|
|
43
|
+
if (!old) throw new Error("Missing fixture node");
|
|
44
|
+
const days = contributionDays(
|
|
45
|
+
{ ...graph, nodes: [...graph.nodes, { ...old, committedAt: 0 }] },
|
|
46
|
+
now,
|
|
47
|
+
);
|
|
48
|
+
expect(days).toHaveLength(182);
|
|
49
|
+
expect(days.at(-1)?.count).toBe(3);
|
|
50
|
+
expect(days.reduce((sum, day) => sum + day.count, 0)).toBe(3);
|
|
51
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { registerHooks } from "node:module";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
6
|
+
import { ensureBonsaiTables, BonsaiState } from "../backend/state.ts";
|
|
7
|
+
import { appBackend } from "../backend/methods.ts";
|
|
8
|
+
import { defaultLayout, insightLayout } from "../shared/insights.ts";
|
|
9
|
+
|
|
10
|
+
const runtimeHooks = registerHooks({
|
|
11
|
+
resolve(specifier, context, nextResolve) {
|
|
12
|
+
if (specifier === "cloudflare:workers")
|
|
13
|
+
return {
|
|
14
|
+
url: import.meta
|
|
15
|
+
.resolve("@fungi.computer/oyster/testing/workers-runtime"),
|
|
16
|
+
shortCircuit: true,
|
|
17
|
+
};
|
|
18
|
+
return nextResolve(specifier, context);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const { BonsaiApp } = await import("../backend/index.ts");
|
|
22
|
+
runtimeHooks.deregister();
|
|
23
|
+
|
|
24
|
+
test("host push question uses authenticated IDs and refuses every update or unsupported shape", async () => {
|
|
25
|
+
const db = new DatabaseSync(":memory:");
|
|
26
|
+
try {
|
|
27
|
+
const storage = fromNodeSqlite(db);
|
|
28
|
+
const started = [];
|
|
29
|
+
const facet = new BonsaiApp(
|
|
30
|
+
{ storage, waitUntil: (promise) => started.push(promise) },
|
|
31
|
+
{ GIT: {}, EVENTS: { emit: () => Promise.resolve() } },
|
|
32
|
+
);
|
|
33
|
+
await Promise.all(started);
|
|
34
|
+
facet.bonsai.state.registerRepo({
|
|
35
|
+
name: "pond",
|
|
36
|
+
description: null,
|
|
37
|
+
parent: null,
|
|
38
|
+
owner: null,
|
|
39
|
+
});
|
|
40
|
+
facet.bonsai.state.registerRepo({
|
|
41
|
+
name: "pond--ada",
|
|
42
|
+
description: null,
|
|
43
|
+
parent: "pond",
|
|
44
|
+
owner: "agent_ada",
|
|
45
|
+
});
|
|
46
|
+
const update = {
|
|
47
|
+
ref: "refs/heads/main",
|
|
48
|
+
before: "a".repeat(40),
|
|
49
|
+
after: "b".repeat(40),
|
|
50
|
+
};
|
|
51
|
+
const question = {
|
|
52
|
+
repository: "pond--ada",
|
|
53
|
+
writer: { kind: "actor", actor: { kind: "agent", agentId: "agent_ada" } },
|
|
54
|
+
updates: [update],
|
|
55
|
+
};
|
|
56
|
+
const ask = (body) => facet.authorizeGitPush(body);
|
|
57
|
+
const allowed = await ask(question);
|
|
58
|
+
assert.deepEqual(allowed, { allowed: true });
|
|
59
|
+
const refused = await ask({
|
|
60
|
+
...question,
|
|
61
|
+
repository: "pond",
|
|
62
|
+
updates: [update, { ...update, ref: "refs/heads/topic" }],
|
|
63
|
+
});
|
|
64
|
+
assert.deepEqual(refused, {
|
|
65
|
+
allowed: false,
|
|
66
|
+
refused: [
|
|
67
|
+
{
|
|
68
|
+
ref: "refs/heads/main",
|
|
69
|
+
message: "Writer cannot push to this repository",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
ref: "refs/heads/topic",
|
|
73
|
+
message: "Writer cannot push to this repository",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
for (const invalid of [
|
|
78
|
+
{ repo: "pond", actor: { name: "levi", kind: "human" } },
|
|
79
|
+
{
|
|
80
|
+
...question,
|
|
81
|
+
writer: {
|
|
82
|
+
kind: "actor",
|
|
83
|
+
actor: { kind: "human", name: "levi" },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{ ...question, updates: Array.from({ length: 257 }, () => update) },
|
|
87
|
+
])
|
|
88
|
+
await assert.rejects(() => ask(invalid));
|
|
89
|
+
const member = await ask({
|
|
90
|
+
...question,
|
|
91
|
+
repository: "pond",
|
|
92
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "levi" } },
|
|
93
|
+
});
|
|
94
|
+
assert.deepEqual(member, { allowed: true });
|
|
95
|
+
assert.deepEqual(facet.bonsai.state.events(null, 20), []);
|
|
96
|
+
} finally {
|
|
97
|
+
db.close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("App methods retain layouts by trusted caller and repository across reopening", async () => {
|
|
102
|
+
const db = new DatabaseSync(":memory:");
|
|
103
|
+
try {
|
|
104
|
+
const storage = fromNodeSqlite(db);
|
|
105
|
+
ensureBonsaiTables(storage.sql);
|
|
106
|
+
const context = {
|
|
107
|
+
bonsai: {
|
|
108
|
+
state: new BonsaiState(storage.sql, (body) => body()),
|
|
109
|
+
events: { emit: () => Promise.resolve() },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const member = (memberId) => ({
|
|
113
|
+
computerId: null,
|
|
114
|
+
actor: { kind: "member", memberId },
|
|
115
|
+
});
|
|
116
|
+
const call = (method, input, caller) =>
|
|
117
|
+
appBackend.call(method, input, context, caller);
|
|
118
|
+
await call(
|
|
119
|
+
"insights.layout.save",
|
|
120
|
+
{ repo: "pond", tiles: defaultLayout },
|
|
121
|
+
member("a"),
|
|
122
|
+
);
|
|
123
|
+
await call(
|
|
124
|
+
"insights.layout.save",
|
|
125
|
+
{ repo: "pond", tiles: { view: "overview", placements: [] } },
|
|
126
|
+
member("b"),
|
|
127
|
+
);
|
|
128
|
+
context.bonsai.state = new BonsaiState(storage.sql, (body) => body());
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
await call("insights.layout.get", { repo: "pond" }, member("a")),
|
|
131
|
+
{ tiles: defaultLayout },
|
|
132
|
+
);
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
await call("insights.layout.get", { repo: "pond" }, member("b")),
|
|
135
|
+
{ tiles: { view: "overview", placements: [] } },
|
|
136
|
+
);
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
await call("insights.layout.get", { repo: "other" }, member("a")),
|
|
139
|
+
{ tiles: null },
|
|
140
|
+
);
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
await call(
|
|
143
|
+
"insights.layout.get",
|
|
144
|
+
{ repo: "pond" },
|
|
145
|
+
{ computerId: null, actor: { kind: "agent", agentId: "a" } },
|
|
146
|
+
),
|
|
147
|
+
{ tiles: null },
|
|
148
|
+
);
|
|
149
|
+
await assert.rejects(() =>
|
|
150
|
+
call(
|
|
151
|
+
"insights.layout.save",
|
|
152
|
+
{
|
|
153
|
+
repo: "pond",
|
|
154
|
+
tiles: { view: "overview", placements: [] },
|
|
155
|
+
actor: "a",
|
|
156
|
+
},
|
|
157
|
+
member("b"),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
await call("insights.layout.get", { repo: "pond" }, member("a")),
|
|
162
|
+
{ tiles: defaultLayout },
|
|
163
|
+
);
|
|
164
|
+
} finally {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("placements reject duplicate tiles, unknown tiles and invalid spans", () => {
|
|
170
|
+
assert.equal(
|
|
171
|
+
insightLayout.safeParse({
|
|
172
|
+
view: "overview",
|
|
173
|
+
placements: [{ id: "network", col: 0, row: 0, w: 0, h: 1 }],
|
|
174
|
+
}).success,
|
|
175
|
+
false,
|
|
176
|
+
);
|
|
177
|
+
assert.equal(
|
|
178
|
+
insightLayout.safeParse({
|
|
179
|
+
view: "overview",
|
|
180
|
+
placements: [defaultLayout.placements[0], defaultLayout.placements[0]],
|
|
181
|
+
}).success,
|
|
182
|
+
false,
|
|
183
|
+
);
|
|
184
|
+
assert.equal(
|
|
185
|
+
insightLayout.safeParse({
|
|
186
|
+
view: "overview",
|
|
187
|
+
placements: [{ id: "invented", col: 0, row: 0, w: 1, h: 1 }],
|
|
188
|
+
}).success,
|
|
189
|
+
false,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("old or invalid stored layouts are discarded without writes; saves stay strict", async () => {
|
|
194
|
+
const db = new DatabaseSync(":memory:");
|
|
195
|
+
try {
|
|
196
|
+
const storage = fromNodeSqlite(db);
|
|
197
|
+
ensureBonsaiTables(storage.sql);
|
|
198
|
+
const state = new BonsaiState(storage.sql, (body) => body());
|
|
199
|
+
const old = JSON.stringify([
|
|
200
|
+
{ id: "branches", col: 0, row: 0, w: 3, h: 2 },
|
|
201
|
+
]);
|
|
202
|
+
storage.sql.exec(
|
|
203
|
+
"INSERT INTO insight_layouts (actor, repo, tiles) VALUES (?, ?, ?)",
|
|
204
|
+
"a",
|
|
205
|
+
"pond",
|
|
206
|
+
old,
|
|
207
|
+
);
|
|
208
|
+
assert.equal(state.layout("a", "pond"), null);
|
|
209
|
+
assert.equal(
|
|
210
|
+
storage.sql
|
|
211
|
+
.exec("SELECT tiles FROM insight_layouts WHERE actor = ?", "a")
|
|
212
|
+
.one().tiles,
|
|
213
|
+
old,
|
|
214
|
+
);
|
|
215
|
+
assert.throws(() => state.saveLayout("a", "pond", JSON.parse(old)));
|
|
216
|
+
assert.equal(
|
|
217
|
+
storage.sql
|
|
218
|
+
.exec("SELECT tiles FROM insight_layouts WHERE actor = ?", "a")
|
|
219
|
+
.one().tiles,
|
|
220
|
+
old,
|
|
221
|
+
);
|
|
222
|
+
state.saveLayout("a", "pond", { view: "overview", placements: [] });
|
|
223
|
+
assert.deepEqual(state.layout("a", "pond"), {
|
|
224
|
+
view: "overview",
|
|
225
|
+
placements: [],
|
|
226
|
+
});
|
|
227
|
+
} finally {
|
|
228
|
+
db.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { bindingOf } from "./git-binding.fixture.js";
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { advanceLineAges, readCodeInsights } from "../backend/insights.js";
|
|
4
|
+
import type { GitStore } from "../backend/git.js";
|
|
5
|
+
import type { Commit } from "@fungi.computer/agar/roles/files";
|
|
6
|
+
|
|
7
|
+
it("preserves unchanged ages while deleting and replacing lines", () => {
|
|
8
|
+
expect(
|
|
9
|
+
advanceLineAges("old\nkeep\ndelete\n", "new\nkeep\n", [1, 2, 3], 10),
|
|
10
|
+
).toEqual([10, 2]);
|
|
11
|
+
expect(advanceLineAges("", "last", [], 5)).toEqual([5]);
|
|
12
|
+
expect(advanceLineAges("same\n", "same\n", [null], 5)).toEqual([null]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function fixture(length = 3) {
|
|
16
|
+
let reads = 0;
|
|
17
|
+
const commit = (index: number): Commit => ({
|
|
18
|
+
hash: String(index),
|
|
19
|
+
treeHash: `tree${String(index)}`,
|
|
20
|
+
parents: index > 1 ? [String(index - 1)] : [],
|
|
21
|
+
message: "edit",
|
|
22
|
+
author: { name: "a", email: "a@local" },
|
|
23
|
+
committer: { name: "a", email: "a@local" },
|
|
24
|
+
authoredAt: index * 86400,
|
|
25
|
+
committedAt: index * 86400,
|
|
26
|
+
});
|
|
27
|
+
const unused = () => Promise.reject(new Error("Unexpected Git operation"));
|
|
28
|
+
const store: GitStore = {
|
|
29
|
+
get binding() {
|
|
30
|
+
return bindingOf(this);
|
|
31
|
+
},
|
|
32
|
+
cloneUrl: unused,
|
|
33
|
+
create: unused,
|
|
34
|
+
fork: unused,
|
|
35
|
+
info: unused,
|
|
36
|
+
refs: unused,
|
|
37
|
+
exchange: unused,
|
|
38
|
+
log: () => Promise.resolve([commit(length)]),
|
|
39
|
+
commit: (_repo, hash) => Promise.resolve(commit(Number(hash))),
|
|
40
|
+
tree: (_repo, hash) => {
|
|
41
|
+
reads++;
|
|
42
|
+
return Promise.resolve([
|
|
43
|
+
{
|
|
44
|
+
name: "file.ts",
|
|
45
|
+
hash: hash === "tree1" ? "blob1" : "blob2",
|
|
46
|
+
mode: "100644",
|
|
47
|
+
type: "blob",
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
},
|
|
51
|
+
blob: (_repo, hash) => {
|
|
52
|
+
reads++;
|
|
53
|
+
return Promise.resolve(
|
|
54
|
+
new TextEncoder().encode(
|
|
55
|
+
hash === "blob1" ? "keep\nold\n" : "keep\nnew\n",
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { store, reads: () => reads };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it("counts current lines and changed blobs, and shares a cached snapshot", async () => {
|
|
64
|
+
const f = fixture();
|
|
65
|
+
const result = await readCodeInsights(f.store, "repo", "main", 4 * 86400000);
|
|
66
|
+
expect(result.files).toEqual([{ path: "file.ts", lines: 2, changes: 2 }]);
|
|
67
|
+
expect(result.ageHistory.at(-1)?.lines[0]).toBe(2);
|
|
68
|
+
expect(result.partial).toBe(false);
|
|
69
|
+
const reads = f.reads();
|
|
70
|
+
expect(await readCodeInsights(f.store, "repo", "main", 4 * 86400000)).toEqual(
|
|
71
|
+
result,
|
|
72
|
+
);
|
|
73
|
+
expect(f.reads()).toBe(reads);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("leaves pre-sample lines unknown instead of assigning a fabricated birthday", async () => {
|
|
77
|
+
const f = fixture(50);
|
|
78
|
+
const result = await readCodeInsights(f.store, "repo", "main", 60 * 86400000);
|
|
79
|
+
expect(result.commits).toBe(40);
|
|
80
|
+
expect(result.partial).toBe(true);
|
|
81
|
+
expect(result.ageHistory.at(-1)?.lines[4]).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("bounds text-file sampling and reports omissions", async () => {
|
|
85
|
+
const f = fixture(1);
|
|
86
|
+
const store: GitStore = {
|
|
87
|
+
get binding() {
|
|
88
|
+
return bindingOf(this);
|
|
89
|
+
},
|
|
90
|
+
...f.store,
|
|
91
|
+
tree: () =>
|
|
92
|
+
Promise.resolve(
|
|
93
|
+
Array.from({ length: 100 }, (_, index) => ({
|
|
94
|
+
name: `file${String(index)}.ts`,
|
|
95
|
+
hash: "blob1",
|
|
96
|
+
mode: "100644",
|
|
97
|
+
type: "blob" as const,
|
|
98
|
+
})),
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
const result = await readCodeInsights(store, "large", "main", 4 * 86400000);
|
|
102
|
+
expect(result.files).toHaveLength(80);
|
|
103
|
+
expect(result.omittedFiles).toBe(20);
|
|
104
|
+
expect(result.partial).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not report unavailable Git objects as empty successful data", async () => {
|
|
108
|
+
const f = fixture(1);
|
|
109
|
+
const store: GitStore = { ...f.store, blob: () => Promise.resolve(null) };
|
|
110
|
+
await expect(readCodeInsights(store, "broken", "main")).rejects.toThrow(
|
|
111
|
+
"Git blob is missing",
|
|
112
|
+
);
|
|
113
|
+
await expect(readCodeInsights(store, "broken", "main")).rejects.toThrow(
|
|
114
|
+
"Git blob is missing",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("ages unchanged lines between historical snapshots and today", async () => {
|
|
119
|
+
const f = fixture();
|
|
120
|
+
const result = await readCodeInsights(f.store, "ages", "main", 40 * 86400000);
|
|
121
|
+
expect(result.ageHistory.map((snapshot) => snapshot.at)).toEqual([
|
|
122
|
+
86400,
|
|
123
|
+
2 * 86400,
|
|
124
|
+
3 * 86400,
|
|
125
|
+
40 * 86400,
|
|
126
|
+
]);
|
|
127
|
+
expect(result.ageHistory[0]?.lines).toEqual([2, 0, 0, 0, 0]);
|
|
128
|
+
expect(result.ageHistory.at(-1)?.lines).toEqual([0, 0, 2, 0, 0]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("retains historical line counts when a later snapshot deletes lines", async () => {
|
|
132
|
+
const f = fixture();
|
|
133
|
+
const store: GitStore = {
|
|
134
|
+
get binding() {
|
|
135
|
+
return bindingOf(this);
|
|
136
|
+
},
|
|
137
|
+
...f.store,
|
|
138
|
+
blob: (_repo, hash) =>
|
|
139
|
+
Promise.resolve(
|
|
140
|
+
new TextEncoder().encode(
|
|
141
|
+
hash === "blob1" ? "keep\ndelete\n" : "keep\n",
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
const result = await readCodeInsights(
|
|
146
|
+
store,
|
|
147
|
+
"deletions",
|
|
148
|
+
"main",
|
|
149
|
+
40 * 86400000,
|
|
150
|
+
);
|
|
151
|
+
expect(result.ageHistory[0]?.lines.reduce((a, b) => a + b, 0)).toBe(2);
|
|
152
|
+
expect(result.ageHistory[1]?.lines.reduce((a, b) => a + b, 0)).toBe(1);
|
|
153
|
+
expect(result.ageHistory.at(-1)?.lines).toEqual([0, 0, 1, 0, 0]);
|
|
154
|
+
});
|