@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,234 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { env as processEnv } from "node:process";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { URL, pathToFileURL } from "node:url";
|
|
9
|
+
import { Script } from "node:vm";
|
|
10
|
+
import * as S from "effect/Schema";
|
|
11
|
+
import * as z from "zod";
|
|
12
|
+
import {
|
|
13
|
+
catalogEvents,
|
|
14
|
+
catalogMethods,
|
|
15
|
+
compile,
|
|
16
|
+
triggerRun,
|
|
17
|
+
validate,
|
|
18
|
+
workflowDefinitionSchema,
|
|
19
|
+
} from "@fungi.computer/braid";
|
|
20
|
+
// Shed's source is pinned at runtime only; Bonsai's compiler options do not own it.
|
|
21
|
+
import { activityEnvelope, hookEvent } from "../shared/hooks.ts";
|
|
22
|
+
import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
|
|
23
|
+
import { appBackend as bonsaiBackend } from "../backend/methods.ts";
|
|
24
|
+
import { appBackend as shedBackend } from "../../shed/backend/methods.ts";
|
|
25
|
+
const bonsaiApi = buildAppApi(bonsaiBackend);
|
|
26
|
+
const shedApi = buildAppApi(shedBackend);
|
|
27
|
+
|
|
28
|
+
// The stock Workflows template that reports checks onto Bonsai Changes.
|
|
29
|
+
async function checksTemplate() {
|
|
30
|
+
const template = z
|
|
31
|
+
.object({ definition: z.json() })
|
|
32
|
+
.parse(
|
|
33
|
+
JSON.parse(
|
|
34
|
+
await readFile(
|
|
35
|
+
new URL(
|
|
36
|
+
"../../trellis/templates/bonsai-checks.json",
|
|
37
|
+
import.meta.url,
|
|
38
|
+
),
|
|
39
|
+
"utf8",
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
return S.decodeUnknownSync(workflowDefinitionSchema)(template.definition);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function installedCatalog() {
|
|
47
|
+
return {
|
|
48
|
+
app_bonsai: {
|
|
49
|
+
methods: (await catalogMethods(bonsaiApi.methods)).methods,
|
|
50
|
+
events: (await catalogEvents(bonsaiApi.events)).events,
|
|
51
|
+
},
|
|
52
|
+
app_shed: { methods: (await catalogMethods(shedApi.methods)).methods },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const change = {
|
|
57
|
+
repo: "pond",
|
|
58
|
+
number: 12,
|
|
59
|
+
sourceRepo: "pond--morel",
|
|
60
|
+
sourceRef: "main",
|
|
61
|
+
targetRef: "main",
|
|
62
|
+
head: "b".repeat(40),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
test("pins the checks template to Bonsai's and Shed's maintained declarations", async () => {
|
|
66
|
+
const definition = await checksTemplate();
|
|
67
|
+
const catalog = await installedCatalog();
|
|
68
|
+
assert.deepEqual(validate(definition, catalog), []);
|
|
69
|
+
const compiled = await compile(definition, catalog, {
|
|
70
|
+
bonsai: { appId: "app_bonsai", installationId: "bonsai" },
|
|
71
|
+
shed: { appId: "app_shed", installationId: "shed" },
|
|
72
|
+
});
|
|
73
|
+
assert.ok(compiled.ok, JSON.stringify(compiled));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("starts checks at the Change head from each emitted Bonsai Change event", async () => {
|
|
77
|
+
const definition = await checksTemplate();
|
|
78
|
+
for (const type of [
|
|
79
|
+
"change.opened",
|
|
80
|
+
"change.updated",
|
|
81
|
+
"change.checks_requested",
|
|
82
|
+
]) {
|
|
83
|
+
const trigger = definition.triggers?.find(
|
|
84
|
+
(item) => item.on === "event" && item.event === type,
|
|
85
|
+
);
|
|
86
|
+
assert.ok(trigger, type);
|
|
87
|
+
// The payload is the envelope Bonsai's outbox emits for a stored activity.
|
|
88
|
+
const payload = activityEnvelope(
|
|
89
|
+
{
|
|
90
|
+
id: 12,
|
|
91
|
+
at: Date.UTC(2026, 9, 13, 8),
|
|
92
|
+
type,
|
|
93
|
+
repo: "pond",
|
|
94
|
+
actor: "morel",
|
|
95
|
+
data: { change },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "pond--morel",
|
|
99
|
+
canonical: "pond",
|
|
100
|
+
cloneUrl: "https://bonsai.example/git/pond--morel.git",
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
const outcome = triggerRun(definition, trigger.id, {
|
|
104
|
+
eventId: payload.data.eventId,
|
|
105
|
+
payload,
|
|
106
|
+
});
|
|
107
|
+
assert.equal(outcome.kind, "start", type);
|
|
108
|
+
assert.equal(outcome.input.testCommand, "npm test");
|
|
109
|
+
// The read pass is scoped to the source repository holding the head.
|
|
110
|
+
assert.equal(outcome.repository, "pond--morel", type);
|
|
111
|
+
const started = hookEvent.parse(outcome.input.payload);
|
|
112
|
+
assert.equal(started.data.eventId, "evt_12");
|
|
113
|
+
assert.deepEqual(started.data.change, change);
|
|
114
|
+
}
|
|
115
|
+
assert.ok(
|
|
116
|
+
!definition.triggers?.some(
|
|
117
|
+
(item) => item.on === "event" && item.event === "git.push",
|
|
118
|
+
),
|
|
119
|
+
"checks report on Changes, not bare pushes",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("clones only through the step's read-only pass for the event's repository", async () => {
|
|
124
|
+
const definition = await checksTemplate();
|
|
125
|
+
const tests = definition.nodes.find((node) => node.id === "tests");
|
|
126
|
+
assert.ok(tests?.kind === "app");
|
|
127
|
+
assert.equal(tests.app, "shed");
|
|
128
|
+
assert.equal(tests.method, "runCommand");
|
|
129
|
+
assert.deepEqual(tests.gitAccess, {
|
|
130
|
+
binding: "bonsai",
|
|
131
|
+
repository: "$trigger.repository",
|
|
132
|
+
access: "read",
|
|
133
|
+
});
|
|
134
|
+
const commandNode = definition.nodes.find((node) => node.id === "command");
|
|
135
|
+
assert.ok(commandNode?.kind === "code");
|
|
136
|
+
assert.match(commandNode.source, /"\$FUNGI_GIT_REMOTE"/);
|
|
137
|
+
assert.doesNotMatch(commandNode.source, /cloneUrl/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("runs tests on the pinned Change head even after the source branch moves", async (t) => {
|
|
141
|
+
const definition = await checksTemplate();
|
|
142
|
+
const commandNode = definition.nodes.find((node) => node.id === "command");
|
|
143
|
+
const resultNode = definition.nodes.find((node) => node.id === "result");
|
|
144
|
+
assert.ok(commandNode?.kind === "code" && resultNode?.kind === "code");
|
|
145
|
+
const work = await mkdtemp(join(tmpdir(), "bonsai-check-head-"));
|
|
146
|
+
t.after(() => rm(work, { recursive: true, force: true }));
|
|
147
|
+
const source = join(work, "source");
|
|
148
|
+
execFileSync("git", ["init", "--initial-branch=main", source]);
|
|
149
|
+
const git = (...args) =>
|
|
150
|
+
execFileSync("git", ["-C", source, ...args], { encoding: "utf8" }).trim();
|
|
151
|
+
git("config", "user.name", "Morel");
|
|
152
|
+
git("config", "user.email", "morel@test.invalid");
|
|
153
|
+
await writeFile(
|
|
154
|
+
join(source, "package.json"),
|
|
155
|
+
JSON.stringify({ scripts: { test: "node check.mjs" } }),
|
|
156
|
+
);
|
|
157
|
+
await writeFile(
|
|
158
|
+
join(source, "check.mjs"),
|
|
159
|
+
'console.log("PINNED HEAD PASSED");',
|
|
160
|
+
);
|
|
161
|
+
git("add", ".");
|
|
162
|
+
git("commit", "-m", "Passing Change head");
|
|
163
|
+
const head = git("rev-parse", "HEAD");
|
|
164
|
+
await writeFile(
|
|
165
|
+
join(source, "check.mjs"),
|
|
166
|
+
'console.error("MOVED HEAD FAILED"); process.exit(1);',
|
|
167
|
+
);
|
|
168
|
+
git("commit", "-am", "New failing head");
|
|
169
|
+
const movedHead = git("rev-parse", "HEAD");
|
|
170
|
+
// Only the external Code execution boundary is replaced by Node VM here.
|
|
171
|
+
// The generated command itself runs real Git and npm in an isolated directory.
|
|
172
|
+
const evaluate = async (code, input) =>
|
|
173
|
+
z.json().parse(
|
|
174
|
+
await new Script(`(async () => { ${code} })()`).runInNewContext({
|
|
175
|
+
env: { INPUT: { read: () => Promise.resolve(input) } },
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
for (const [selected, expected] of [
|
|
179
|
+
[head, "passed"],
|
|
180
|
+
[movedHead, "failed"],
|
|
181
|
+
]) {
|
|
182
|
+
const command = z.object({ command: z.string() }).parse(
|
|
183
|
+
await evaluate(commandNode.source, {
|
|
184
|
+
head: selected,
|
|
185
|
+
testCommand: "npm test",
|
|
186
|
+
report: { kind: "recorded" },
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
// Shed hands the pass's remote to the command as $FUNGI_GIT_REMOTE.
|
|
190
|
+
const execution = spawnSync("sh", ["-c", command.command], {
|
|
191
|
+
env: { ...processEnv, FUNGI_GIT_REMOTE: pathToFileURL(source).href },
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
194
|
+
});
|
|
195
|
+
if (execution.error !== undefined) throw execution.error;
|
|
196
|
+
assert.notEqual(execution.status, null, execution.signal ?? "");
|
|
197
|
+
const result = z
|
|
198
|
+
.object({ status: z.enum(["passed", "failed"]), summary: z.string() })
|
|
199
|
+
.parse(
|
|
200
|
+
await evaluate(resultNode.source, {
|
|
201
|
+
status: execution.status === 0 ? "succeeded" : "failed",
|
|
202
|
+
output: {
|
|
203
|
+
status: execution.status === 0 ? "completed" : "failed",
|
|
204
|
+
exitCode: execution.status,
|
|
205
|
+
stdout: execution.stdout,
|
|
206
|
+
stderr: execution.stderr,
|
|
207
|
+
truncated: false,
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
assert.equal(result.status, expected);
|
|
212
|
+
assert.match(
|
|
213
|
+
result.summary,
|
|
214
|
+
expected === "passed" ? /PINNED HEAD PASSED/ : /MOVED HEAD FAILED/,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
evaluate(commandNode.source, {
|
|
219
|
+
head,
|
|
220
|
+
testCommand: "npm test",
|
|
221
|
+
report: { kind: "moved", head: movedHead },
|
|
222
|
+
}),
|
|
223
|
+
/Check attempt is no longer current/,
|
|
224
|
+
);
|
|
225
|
+
for (const refusal of [
|
|
226
|
+
{ error: "not_granted" },
|
|
227
|
+
{ status: "running", output: null },
|
|
228
|
+
]) {
|
|
229
|
+
const result = z
|
|
230
|
+
.object({ status: z.string() })
|
|
231
|
+
.parse(await evaluate(resultNode.source, refusal));
|
|
232
|
+
assert.equal(result.status, "failed");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { bindingOf } from "./git-binding.fixture.js";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { GitStore } from "../backend/git.js";
|
|
4
|
+
import { decodeText, isBinary, treeDiff } from "../backend/diff.js";
|
|
5
|
+
|
|
6
|
+
describe("git blob text decoding", () => {
|
|
7
|
+
it("distinguishes an empty text blob from invalid UTF-8", () => {
|
|
8
|
+
expect(decodeText(new Uint8Array())).toEqual({ kind: "text", text: "" });
|
|
9
|
+
expect(decodeText(new Uint8Array([0xff]))).toEqual({
|
|
10
|
+
kind: "invalid-utf8",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("keeps valid multi-byte text and the bounded binary probe", () => {
|
|
15
|
+
const text = `${"a".repeat(7999)}é`;
|
|
16
|
+
expect(decodeText(new TextEncoder().encode(text))).toEqual({
|
|
17
|
+
kind: "text",
|
|
18
|
+
text,
|
|
19
|
+
});
|
|
20
|
+
expect(isBinary(new TextEncoder().encode(text))).toBe(false);
|
|
21
|
+
expect(isBinary(new Uint8Array([0, 65]))).toBe(true);
|
|
22
|
+
expect(isBinary(new Uint8Array([0xff]))).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it.each(["100644", "100755", "120000"])(
|
|
27
|
+
"marks invalid UTF-8 in a %s blob as binary, not an empty text diff",
|
|
28
|
+
async (mode) => {
|
|
29
|
+
const invalid = new Uint8Array(8001).fill(65);
|
|
30
|
+
invalid[8000] = 255;
|
|
31
|
+
const unused = () => Promise.reject(new Error("Unexpected Git operation"));
|
|
32
|
+
const store: GitStore = {
|
|
33
|
+
get binding() {
|
|
34
|
+
return bindingOf(this);
|
|
35
|
+
},
|
|
36
|
+
cloneUrl: unused,
|
|
37
|
+
create: unused,
|
|
38
|
+
fork: unused,
|
|
39
|
+
info: unused,
|
|
40
|
+
log: unused,
|
|
41
|
+
commit: unused,
|
|
42
|
+
refs: unused,
|
|
43
|
+
exchange: unused,
|
|
44
|
+
tree: (_repo, hash) =>
|
|
45
|
+
Promise.resolve([
|
|
46
|
+
{
|
|
47
|
+
name: "late.bin",
|
|
48
|
+
mode,
|
|
49
|
+
type: "blob",
|
|
50
|
+
hash: hash === "old" ? "a".repeat(40) : "b".repeat(40),
|
|
51
|
+
},
|
|
52
|
+
]),
|
|
53
|
+
blob: (_repo, hash) =>
|
|
54
|
+
Promise.resolve(
|
|
55
|
+
hash === "a".repeat(40)
|
|
56
|
+
? new TextEncoder().encode("old text")
|
|
57
|
+
: invalid,
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
const result = await treeDiff(store, "pond", "old", "new");
|
|
61
|
+
expect(result.files).toHaveLength(1);
|
|
62
|
+
expect(result.files[0]).toMatchObject({
|
|
63
|
+
binary: true,
|
|
64
|
+
oldText: null,
|
|
65
|
+
newText: null,
|
|
66
|
+
additions: 0,
|
|
67
|
+
deletions: 0,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { createCapabilityRegistry } from "@fungi.computer/agar";
|
|
7
|
+
import {
|
|
8
|
+
appsOpen,
|
|
9
|
+
type AppOpenInput,
|
|
10
|
+
type AppRoleChoice,
|
|
11
|
+
} from "@fungi.computer/agar/apps";
|
|
12
|
+
import { appMethods } from "@fungi.computer/agar/backend";
|
|
13
|
+
import { CodeTab } from "../web/code-tab.js";
|
|
14
|
+
import { RouterProvider } from "../web/router.js";
|
|
15
|
+
import { connect } from "../web/transport.js";
|
|
16
|
+
const clean: (() => Promise<void> | void)[] = [];
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
for (const dispose of clean.splice(0)) await dispose();
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
connect(null);
|
|
21
|
+
});
|
|
22
|
+
async function fixture(
|
|
23
|
+
granted: boolean,
|
|
24
|
+
binary = false,
|
|
25
|
+
pending?: { text: string; revision: number; state: "pending" | "failed" },
|
|
26
|
+
/** The Team's files-role choice as the shell's role routing reports it. */
|
|
27
|
+
filesRole: AppRoleChoice = { displayName: "Quill" },
|
|
28
|
+
) {
|
|
29
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
30
|
+
matches: false,
|
|
31
|
+
addEventListener() {
|
|
32
|
+
return undefined;
|
|
33
|
+
},
|
|
34
|
+
removeEventListener() {
|
|
35
|
+
return undefined;
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
vi.stubGlobal(
|
|
39
|
+
"ResizeObserver",
|
|
40
|
+
class {
|
|
41
|
+
observe() {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
disconnect() {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
unobserve() {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
53
|
+
const opened: AppOpenInput[] = [];
|
|
54
|
+
const asked: string[] = [];
|
|
55
|
+
const capabilities = createCapabilityRegistry();
|
|
56
|
+
if (granted)
|
|
57
|
+
capabilities.provide(appsOpen, {
|
|
58
|
+
open: (input) => {
|
|
59
|
+
opened.push(input);
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
},
|
|
62
|
+
role: (role) => {
|
|
63
|
+
asked.push(role);
|
|
64
|
+
return Promise.resolve(filesRole);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const called: string[] = [];
|
|
68
|
+
capabilities.provide(appMethods, {
|
|
69
|
+
call: (method) => {
|
|
70
|
+
called.push(method);
|
|
71
|
+
return Promise.resolve({ settled: [], more: true });
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
connect({ capabilities });
|
|
75
|
+
const client = new QueryClient({
|
|
76
|
+
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
|
77
|
+
});
|
|
78
|
+
const commit = {
|
|
79
|
+
hash: "a".repeat(40),
|
|
80
|
+
treeHash: "b".repeat(40),
|
|
81
|
+
message: "Plant pond",
|
|
82
|
+
author: { name: "seed", email: "seed@test.invalid" },
|
|
83
|
+
committer: { name: "seed", email: "seed@test.invalid" },
|
|
84
|
+
parents: [],
|
|
85
|
+
authoredAt: 0,
|
|
86
|
+
committedAt: 0,
|
|
87
|
+
};
|
|
88
|
+
client.setQueryData(["files", "pond", "main"], {
|
|
89
|
+
ref: "main",
|
|
90
|
+
commit,
|
|
91
|
+
paths: ["README.md"],
|
|
92
|
+
truncated: false,
|
|
93
|
+
});
|
|
94
|
+
const read = {
|
|
95
|
+
ref: "main",
|
|
96
|
+
path: "README.md",
|
|
97
|
+
commit: commit.hash,
|
|
98
|
+
blob: "c".repeat(40),
|
|
99
|
+
size: 7,
|
|
100
|
+
binary,
|
|
101
|
+
text: binary ? null : "# Pond",
|
|
102
|
+
version: pending?.revision ?? null,
|
|
103
|
+
};
|
|
104
|
+
client.setQueryData(
|
|
105
|
+
["blob", "pond", "main", "README.md"],
|
|
106
|
+
pending === undefined ? read : { ...read, pending },
|
|
107
|
+
);
|
|
108
|
+
const container = document.createElement("div");
|
|
109
|
+
document.body.appendChild(container);
|
|
110
|
+
const root = createRoot(container);
|
|
111
|
+
clean.push(async () => {
|
|
112
|
+
await act(() => {
|
|
113
|
+
root.unmount();
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
});
|
|
116
|
+
client.clear();
|
|
117
|
+
container.remove();
|
|
118
|
+
});
|
|
119
|
+
await act(() => {
|
|
120
|
+
root.render(
|
|
121
|
+
createElement(
|
|
122
|
+
QueryClientProvider,
|
|
123
|
+
{ client },
|
|
124
|
+
createElement(
|
|
125
|
+
RouterProvider,
|
|
126
|
+
null,
|
|
127
|
+
createElement(CodeTab, {
|
|
128
|
+
route: {
|
|
129
|
+
page: "repo",
|
|
130
|
+
repo: "pond",
|
|
131
|
+
tab: "code",
|
|
132
|
+
ref: null,
|
|
133
|
+
file: "README.md",
|
|
134
|
+
path: "",
|
|
135
|
+
},
|
|
136
|
+
detail: {
|
|
137
|
+
repo: {
|
|
138
|
+
name: "pond",
|
|
139
|
+
description: null,
|
|
140
|
+
parent: null,
|
|
141
|
+
owner: null,
|
|
142
|
+
createdAt: 0,
|
|
143
|
+
lastPushAt: null,
|
|
144
|
+
forks: [],
|
|
145
|
+
openChanges: 0,
|
|
146
|
+
activity: [],
|
|
147
|
+
},
|
|
148
|
+
defaultBranch: "main",
|
|
149
|
+
refs: [],
|
|
150
|
+
cloneUrl: "https://example.test/pond.git",
|
|
151
|
+
empty: false,
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
// Let the role lookup settle before reading the rendered entry.
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
});
|
|
160
|
+
await act(async () => {
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
162
|
+
});
|
|
163
|
+
return { container, opened, called, asked };
|
|
164
|
+
}
|
|
165
|
+
const editButton = (container: HTMLElement) =>
|
|
166
|
+
Array.from(container.querySelectorAll("button")).find((button) =>
|
|
167
|
+
button.textContent.startsWith("Edit"),
|
|
168
|
+
);
|
|
169
|
+
it("Edit opens the Team's files-role App, named by the shell, with the repository, branch and path", async () => {
|
|
170
|
+
const f = await fixture(true);
|
|
171
|
+
const button = editButton(f.container);
|
|
172
|
+
if (button === undefined) throw new Error("Missing edit button");
|
|
173
|
+
expect(button.textContent).toBe("Edit in Quill");
|
|
174
|
+
expect(f.asked).toEqual(["files"]);
|
|
175
|
+
await act(() => {
|
|
176
|
+
button.click();
|
|
177
|
+
return Promise.resolve();
|
|
178
|
+
});
|
|
179
|
+
expect(f.opened).toEqual([
|
|
180
|
+
{
|
|
181
|
+
role: "files",
|
|
182
|
+
intent: {
|
|
183
|
+
kind: "navigate",
|
|
184
|
+
params: [
|
|
185
|
+
{ key: "repository", value: "pond" },
|
|
186
|
+
{ key: "branch", value: "main" },
|
|
187
|
+
{ key: "path", value: "README.md" },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
it("an unnamed files-role App gets a neutral Edit", async () => {
|
|
194
|
+
const f = await fixture(true, false, undefined, { displayName: null });
|
|
195
|
+
expect(editButton(f.container)?.textContent).toBe("Edit");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it.each([
|
|
199
|
+
["without apps.open", false, false, { displayName: "Quill" }],
|
|
200
|
+
["for a binary file", true, true, { displayName: "Quill" }],
|
|
201
|
+
["when no App fills the files role", true, false, null],
|
|
202
|
+
] as const)("hides Edit %s", async (_case, granted, binary, filesRole) => {
|
|
203
|
+
const f = await fixture(granted, binary, undefined, filesRole);
|
|
204
|
+
expect(editButton(f.container)).toBeUndefined();
|
|
205
|
+
expect(f.opened).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("a saved but unsynced edit shows its text and Sync asks Bonsai for this member's status", async () => {
|
|
209
|
+
const f = await fixture(true, false, {
|
|
210
|
+
text: "# Pond, edited",
|
|
211
|
+
revision: 2,
|
|
212
|
+
state: "failed",
|
|
213
|
+
});
|
|
214
|
+
expect(f.container.textContent).toContain(
|
|
215
|
+
"Sync failed · Saved text retained",
|
|
216
|
+
);
|
|
217
|
+
expect(f.container.textContent).toContain("Pond, edited");
|
|
218
|
+
const sync = Array.from(f.container.querySelectorAll("button")).find(
|
|
219
|
+
(button) => button.textContent === "Sync",
|
|
220
|
+
);
|
|
221
|
+
if (sync === undefined) throw new Error("Missing Sync button");
|
|
222
|
+
await act(async () => {
|
|
223
|
+
sync.click();
|
|
224
|
+
await Promise.resolve();
|
|
225
|
+
});
|
|
226
|
+
expect(f.called[0]).toBe("files.sync");
|
|
227
|
+
});
|