@fungi.computer/bonsai 0.1.0 → 0.2.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 +73 -0
- package/README.md +66 -12
- package/backend/build-workflow.ts +137 -0
- package/backend/index.ts +13 -7
- package/backend/methods.ts +209 -19
- package/package.json +54 -18
- package/scripts/release.mjs +12 -1
- package/scripts/shed-terminal-proof.mjs +10 -8
- package/shared/contract.ts +41 -0
- package/test/apps-build-install.test.ts +399 -26
- package/test/browser/build-workflow.browser.mjs +104 -0
- package/test/browser/repository-browser.fixture.mjs +84 -0
- package/test/browser/repository-settings-fixture.tsx +198 -2
- package/test/browser/repository-settings.browser.mjs +188 -26
- package/test/checks-workflow-template.test.mjs +12 -10
- package/test/favicon.test.mjs +25 -0
- package/test/git-push.native.mjs +30 -0
- package/test/git-push.worker.ts +51 -3
- package/test/merge-template-build.test.mjs +44 -201
- package/test/storyboard-save-https-git.fixture.mjs +35 -29
- package/test/storyboard-save-template.test.mjs +61 -12
- package/vite.config.ts +19 -1
- package/web/app-build-panel.tsx +183 -95
- package/web/bonsai.css +1 -0
- package/web/index.html +0 -1
- package/web/repo-settings.tsx +1 -1
- package/web/public/favicon.svg +0 -1
|
@@ -1,24 +1,34 @@
|
|
|
1
|
+
import * as S from "effect/Schema";
|
|
2
|
+
import { workflowDefinitionSchema } from "@fungi.computer/braid";
|
|
3
|
+
import { repositoryWorkflowSourceSchema } from "@fungi.computer/workflows-wire";
|
|
4
|
+
import { runReply, statusReply } from "@fungi.computer/workflows-wire";
|
|
5
|
+
import type { BonsaiEnv } from "../backend/index.js";
|
|
6
|
+
import type { GraphStartRequest } from "@fungi.computer/braid/runtime";
|
|
1
7
|
import { DatabaseSync } from "node:sqlite";
|
|
2
8
|
import { describe, expect, it } from "vitest";
|
|
3
9
|
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
4
10
|
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
5
11
|
import type { GitBinding } from "@fungi.computer/agar/git";
|
|
6
|
-
import { appBackend } from "../backend/methods.js";
|
|
12
|
+
import { appBackend, type AppsBindingReply } from "../backend/methods.js";
|
|
7
13
|
import { gitStore } from "../backend/git.js";
|
|
8
14
|
import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
|
|
9
15
|
import type { Bonsai } from "../backend/service.js";
|
|
16
|
+
import type { AppBuildInstallation } from "../shared/contract.js";
|
|
10
17
|
import {
|
|
11
18
|
appBackendBindingOperation,
|
|
12
19
|
scopeOperationToMethod,
|
|
13
20
|
} from "@fungi.computer/puffball/app";
|
|
14
21
|
|
|
15
22
|
type Calls = {
|
|
23
|
+
register?: unknown;
|
|
16
24
|
build?: unknown;
|
|
17
25
|
buildStatus?: unknown;
|
|
18
26
|
select?: unknown;
|
|
27
|
+
workflow?: GraphStartRequest;
|
|
28
|
+
workflowRuns?: { workflowId?: string };
|
|
19
29
|
};
|
|
20
30
|
|
|
21
|
-
/** apps.build/apps.buildStatus/apps.install never touch Git or Bonsai's own
|
|
31
|
+
/** apps.register/apps.build/apps.buildStatus/apps.install never touch Git or Bonsai's own
|
|
22
32
|
* repository state; a real (empty) BonsaiState plus an always-unavailable
|
|
23
33
|
* GitBinding give a fully typed Bonsai without faking its shape. apps.fork
|
|
24
34
|
* reads the one repository the host forked, through `git`. */
|
|
@@ -49,22 +59,29 @@ function bonsaiFixture(git: Partial<GitBinding> = {}): Bonsai {
|
|
|
49
59
|
};
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
function fixture(
|
|
62
|
+
function fixture(
|
|
63
|
+
registrationReply?: AppsBindingReply,
|
|
64
|
+
buildReply?: AppsBindingReply,
|
|
65
|
+
installation?: AppBuildInstallation,
|
|
66
|
+
buildStatusReply?: AppsBindingReply,
|
|
67
|
+
selectReply?: AppsBindingReply,
|
|
68
|
+
) {
|
|
53
69
|
const calls: Calls = {};
|
|
54
70
|
const build = {
|
|
55
71
|
jobId: "build_test",
|
|
56
|
-
appId: "
|
|
72
|
+
appId: "app_myfork",
|
|
57
73
|
releaseId: "rel_test",
|
|
58
74
|
sourceRevision: `git:${"a".repeat(40)}`,
|
|
59
75
|
status: "running" as const,
|
|
60
76
|
detail: null,
|
|
61
77
|
release: null,
|
|
78
|
+
installation: null,
|
|
62
79
|
};
|
|
63
80
|
const release = {
|
|
64
|
-
appId: "
|
|
81
|
+
appId: "app_myfork",
|
|
65
82
|
releaseId: "rel_test",
|
|
66
83
|
artifact: {
|
|
67
|
-
artifactRef: "artifact:
|
|
84
|
+
artifactRef: "artifact:sha256-" + "b".repeat(64),
|
|
68
85
|
artifactDigest: `sha256:${"b".repeat(64)}`,
|
|
69
86
|
provenance: {
|
|
70
87
|
sourceRevision: build.sourceRevision,
|
|
@@ -73,29 +90,105 @@ function fixture() {
|
|
|
73
90
|
},
|
|
74
91
|
},
|
|
75
92
|
};
|
|
76
|
-
|
|
93
|
+
let workflowReply = runReply.parse({
|
|
94
|
+
kind: "started",
|
|
95
|
+
runId: "run_manual",
|
|
96
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
97
|
+
});
|
|
98
|
+
const env: Pick<BonsaiEnv, "APPS" | "WORKFLOWS"> = {
|
|
99
|
+
WORKFLOWS: {
|
|
100
|
+
runs: (input) => {
|
|
101
|
+
calls.workflowRuns = input;
|
|
102
|
+
return Promise.resolve({
|
|
103
|
+
runs: [
|
|
104
|
+
{
|
|
105
|
+
runId: "run_triggered",
|
|
106
|
+
workflowId: "bonsai-build-on-merge",
|
|
107
|
+
commit: "a".repeat(40),
|
|
108
|
+
source: "event" as const,
|
|
109
|
+
initiatedBy: { kind: "member" as const, memberId: "Levi" },
|
|
110
|
+
startedAt: 42,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
runGraph: (input) => {
|
|
116
|
+
calls.workflow = input;
|
|
117
|
+
return Promise.resolve(workflowReply);
|
|
118
|
+
},
|
|
119
|
+
graphStatus: () =>
|
|
120
|
+
Promise.resolve(
|
|
121
|
+
statusReply.parse({
|
|
122
|
+
runId: "run_manual",
|
|
123
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
124
|
+
outcome: "active",
|
|
125
|
+
title: "Build on merge",
|
|
126
|
+
status: "running",
|
|
127
|
+
events: [],
|
|
128
|
+
reviews: [],
|
|
129
|
+
output: null,
|
|
130
|
+
error: null,
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
},
|
|
77
134
|
APPS: {
|
|
135
|
+
repository: () =>
|
|
136
|
+
Promise.resolve(
|
|
137
|
+
S.decodeUnknownSync(repositoryWorkflowSourceSchema)({
|
|
138
|
+
appId: "app_myfork",
|
|
139
|
+
installationId: "bonsai",
|
|
140
|
+
callerAppId: "app_bonsai",
|
|
141
|
+
callerSourceRevision: "git:" + "a".repeat(40),
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
register: (input: { repo: string }) => {
|
|
145
|
+
calls.register = input;
|
|
146
|
+
return Promise.resolve(
|
|
147
|
+
registrationReply ?? {
|
|
148
|
+
status: 200,
|
|
149
|
+
body: {
|
|
150
|
+
appId: "app_myfork",
|
|
151
|
+
sourceRevision: build.sourceRevision,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
},
|
|
78
156
|
fork: () => Promise.reject(new Error("This proof never forks")),
|
|
79
157
|
build: (input: { appId: string; sourceRevision: string }) => {
|
|
80
158
|
calls.build = input;
|
|
81
|
-
return Promise.resolve({ status: 202, body: build });
|
|
159
|
+
return Promise.resolve(buildReply ?? { status: 202, body: build });
|
|
82
160
|
},
|
|
83
|
-
buildStatus: (input: { jobId: string }) => {
|
|
161
|
+
buildStatus: (input: { jobId: string } | { repo: string }) => {
|
|
84
162
|
calls.buildStatus = input;
|
|
85
|
-
return Promise.resolve(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
163
|
+
return Promise.resolve(
|
|
164
|
+
buildStatusReply ?? {
|
|
165
|
+
status: 200,
|
|
166
|
+
body:
|
|
167
|
+
"repo" in input && input.repo === "no-jobs"
|
|
168
|
+
? null
|
|
169
|
+
: "jobId" in input && input.jobId === "build_running"
|
|
170
|
+
? { ...build, jobId: input.jobId }
|
|
171
|
+
: {
|
|
172
|
+
...build,
|
|
173
|
+
status: "succeeded" as const,
|
|
174
|
+
release,
|
|
175
|
+
installation: installation ?? null,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
);
|
|
92
179
|
},
|
|
93
|
-
select: (input: {
|
|
180
|
+
select: (input: {
|
|
181
|
+
jobId: string;
|
|
182
|
+
installationId: string;
|
|
183
|
+
release: unknown;
|
|
184
|
+
}) => {
|
|
94
185
|
calls.select = input;
|
|
95
|
-
return Promise.resolve(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
186
|
+
return Promise.resolve(
|
|
187
|
+
selectReply ?? {
|
|
188
|
+
status: 200,
|
|
189
|
+
body: { installationId: input.installationId, status: "enabled" },
|
|
190
|
+
},
|
|
191
|
+
);
|
|
99
192
|
},
|
|
100
193
|
},
|
|
101
194
|
};
|
|
@@ -128,31 +221,251 @@ function fixture() {
|
|
|
128
221
|
role: "member" as const,
|
|
129
222
|
},
|
|
130
223
|
);
|
|
131
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
owner,
|
|
226
|
+
member,
|
|
227
|
+
calls,
|
|
228
|
+
build,
|
|
229
|
+
release,
|
|
230
|
+
setWorkflowReply: (reply: ReturnType<typeof runReply.parse>) => {
|
|
231
|
+
workflowReply = reply;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
132
234
|
}
|
|
133
235
|
|
|
134
|
-
describe("
|
|
236
|
+
describe("App authoring methods", () => {
|
|
237
|
+
it.each([
|
|
238
|
+
[409, "conflict", "Revision is not a seed of this App's template"],
|
|
239
|
+
[409, "conflict", "App has no build source"],
|
|
240
|
+
[400, "invalid", "Invalid source revision"],
|
|
241
|
+
[503, "unavailable", "Build is unavailable"],
|
|
242
|
+
] as const)(
|
|
243
|
+
"preserves a build refusal (%s) through the App method",
|
|
244
|
+
async (status, code, message) => {
|
|
245
|
+
const f = fixture(undefined, { status, body: { error: message } });
|
|
246
|
+
await expect(
|
|
247
|
+
f.owner("apps.build", {
|
|
248
|
+
appId: "app_myfork",
|
|
249
|
+
sourceRevision: f.build.sourceRevision,
|
|
250
|
+
}),
|
|
251
|
+
).rejects.toSatisfy(
|
|
252
|
+
(error) =>
|
|
253
|
+
error instanceof AppMethodRefusal &&
|
|
254
|
+
error.code === code &&
|
|
255
|
+
error.message === message,
|
|
256
|
+
);
|
|
257
|
+
expect(f.calls.select).toBeUndefined();
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
it("apps.register registers this repository through the host, not Bonsai's Files copy", async () => {
|
|
262
|
+
const f = fixture();
|
|
263
|
+
const result = await f.owner("apps.register", { repo: "my-fork" });
|
|
264
|
+
expect(result).toEqual({
|
|
265
|
+
appId: "app_myfork",
|
|
266
|
+
sourceRevision: "git:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
267
|
+
});
|
|
268
|
+
expect(f.calls.register).toEqual({ repo: "my-fork" });
|
|
269
|
+
expect(f.calls.build).toBeUndefined();
|
|
270
|
+
expect(f.calls.select).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("refuses registration for a non-owner before the host is called", async () => {
|
|
274
|
+
const f = fixture();
|
|
275
|
+
await expect(
|
|
276
|
+
f.member("apps.register", { repo: "my-fork" }),
|
|
277
|
+
).rejects.toSatisfy(
|
|
278
|
+
(error) =>
|
|
279
|
+
error instanceof AppMethodRefusal && error.code === "forbidden",
|
|
280
|
+
);
|
|
281
|
+
expect(f.calls.register).toBeUndefined();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it.each([
|
|
285
|
+
{ repo: "../other" },
|
|
286
|
+
{ repo: "my-fork", recipe: {} },
|
|
287
|
+
{ repo: "my-fork", installationId: "other-app" },
|
|
288
|
+
])(
|
|
289
|
+
"refuses invalid or caller-chosen registration authority: %j",
|
|
290
|
+
async (input) => {
|
|
291
|
+
const f = fixture();
|
|
292
|
+
await expect(f.owner("apps.register", input)).rejects.toThrow();
|
|
293
|
+
expect(f.calls.register).toBeUndefined();
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
it.each([
|
|
298
|
+
[403, "forbidden"],
|
|
299
|
+
[404, "not_found"],
|
|
300
|
+
[409, "conflict"],
|
|
301
|
+
[400, "invalid"],
|
|
302
|
+
[503, "unavailable"],
|
|
303
|
+
])("preserves the host's registration refusal (%s)", async (status, code) => {
|
|
304
|
+
const f = fixture({ status, body: { error: "Registration refused" } });
|
|
305
|
+
await expect(
|
|
306
|
+
f.owner("apps.register", { repo: "my-fork" }),
|
|
307
|
+
).rejects.toSatisfy(
|
|
308
|
+
(error) =>
|
|
309
|
+
error instanceof AppMethodRefusal &&
|
|
310
|
+
error.code === code &&
|
|
311
|
+
error.message === "Registration refused",
|
|
312
|
+
);
|
|
313
|
+
expect(f.calls.build).toBeUndefined();
|
|
314
|
+
expect(f.calls.select).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("the owner can discover automatic build Workflows without a browser run id or a second trigger ledger", async () => {
|
|
318
|
+
const f = fixture();
|
|
319
|
+
const reply = await f.owner("apps.buildWorkflowRuns", {});
|
|
320
|
+
expect(reply).toMatchObject({
|
|
321
|
+
runs: [{ runId: "run_triggered", source: "event" }],
|
|
322
|
+
});
|
|
323
|
+
expect(f.calls.workflowRuns).toEqual({
|
|
324
|
+
workflowId: "bonsai-build-on-merge",
|
|
325
|
+
});
|
|
326
|
+
expect(f.calls.workflow).toBeUndefined();
|
|
327
|
+
expect(f.calls.build).toBeUndefined();
|
|
328
|
+
expect(f.calls.select).toBeUndefined();
|
|
329
|
+
await expect(f.member("apps.buildWorkflowRuns", {})).rejects.toThrow(
|
|
330
|
+
"Owner only",
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("the owner action starts the retained build/verify/install Workflow with its edited target", async () => {
|
|
335
|
+
const f = fixture();
|
|
336
|
+
const input = {
|
|
337
|
+
repo: "my-fork",
|
|
338
|
+
sourceRevision: "git:" + "b".repeat(40),
|
|
339
|
+
installationId: "edited-target",
|
|
340
|
+
requestId: "manual-42",
|
|
341
|
+
};
|
|
342
|
+
expect(await f.owner("apps.startBuildWorkflow", input)).toMatchObject({
|
|
343
|
+
kind: "started",
|
|
344
|
+
runId: "run_manual",
|
|
345
|
+
});
|
|
346
|
+
expect(f.calls.workflow).toMatchObject({
|
|
347
|
+
requestId: "manual-42",
|
|
348
|
+
bindings: { bonsai: "bonsai" },
|
|
349
|
+
input: {
|
|
350
|
+
appId: "app_myfork",
|
|
351
|
+
sourceRevision: input.sourceRevision,
|
|
352
|
+
installationId: "edited-target",
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const definition = S.decodeUnknownSync(workflowDefinitionSchema)(
|
|
356
|
+
f.calls.workflow?.definition,
|
|
357
|
+
);
|
|
358
|
+
expect(definition.nodes.map((node) => node.id)).toEqual([
|
|
359
|
+
"build",
|
|
360
|
+
"verify",
|
|
361
|
+
"install",
|
|
362
|
+
]);
|
|
363
|
+
expect(f.calls.build).toBeUndefined();
|
|
364
|
+
expect(f.calls.select).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
it("preserves a Workflow refusal instead of pretending the owner action started", async () => {
|
|
367
|
+
const f = fixture();
|
|
368
|
+
f.setWorkflowReply({ error: "not_granted" });
|
|
369
|
+
await expect(
|
|
370
|
+
f.owner("apps.startBuildWorkflow", {
|
|
371
|
+
repo: "my-fork",
|
|
372
|
+
sourceRevision: "git:" + "b".repeat(40),
|
|
373
|
+
installationId: "my-fork",
|
|
374
|
+
requestId: "manual-denied",
|
|
375
|
+
}),
|
|
376
|
+
).rejects.toThrow("not_granted");
|
|
377
|
+
expect(f.calls.select).toBeUndefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
135
380
|
it("apps.build starts a job on the APPS binding and returns its id", async () => {
|
|
136
381
|
const f = fixture();
|
|
137
382
|
const result = await f.owner("apps.build", {
|
|
138
|
-
appId: "
|
|
383
|
+
appId: "app_myfork",
|
|
139
384
|
sourceRevision: `git:${"a".repeat(40)}`,
|
|
140
385
|
});
|
|
141
386
|
expect(result).toMatchObject({ jobId: "build_test", status: "running" });
|
|
142
387
|
expect(f.calls.build).toEqual({
|
|
143
|
-
appId: "
|
|
388
|
+
appId: "app_myfork",
|
|
144
389
|
sourceRevision: `git:${"a".repeat(40)}`,
|
|
145
390
|
});
|
|
146
391
|
});
|
|
147
392
|
|
|
393
|
+
it("apps.verifyBuild waits for a running build and returns only the matching succeeded Release", async () => {
|
|
394
|
+
const f = fixture();
|
|
395
|
+
expect(
|
|
396
|
+
await f.owner("apps.verifyBuild", {
|
|
397
|
+
jobId: "build_running",
|
|
398
|
+
sourceRevision: f.build.sourceRevision,
|
|
399
|
+
}),
|
|
400
|
+
).toEqual({ kind: "pending", retryAfterMs: 5000 });
|
|
401
|
+
const verified = await f.owner("apps.verifyBuild", {
|
|
402
|
+
jobId: f.build.jobId,
|
|
403
|
+
sourceRevision: f.build.sourceRevision,
|
|
404
|
+
});
|
|
405
|
+
expect(verified).toMatchObject({ release: f.release });
|
|
406
|
+
expect(f.calls.select).toBeUndefined();
|
|
407
|
+
await expect(
|
|
408
|
+
f.owner("apps.verifyBuild", {
|
|
409
|
+
jobId: f.build.jobId,
|
|
410
|
+
sourceRevision: "git:" + "e".repeat(40),
|
|
411
|
+
}),
|
|
412
|
+
).rejects.toThrow("not the requested commit");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("apps.verifyBuild refuses a failed or missing build without installing", async () => {
|
|
416
|
+
const source = fixture();
|
|
417
|
+
const failed = fixture(undefined, undefined, undefined, {
|
|
418
|
+
status: 200,
|
|
419
|
+
body: {
|
|
420
|
+
...source.build,
|
|
421
|
+
status: "failed",
|
|
422
|
+
detail: "Compiler refused the source",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
await expect(
|
|
426
|
+
failed.owner("apps.verifyBuild", {
|
|
427
|
+
jobId: source.build.jobId,
|
|
428
|
+
sourceRevision: source.build.sourceRevision,
|
|
429
|
+
}),
|
|
430
|
+
).rejects.toThrow("Compiler refused the source");
|
|
431
|
+
expect(failed.calls.select).toBeUndefined();
|
|
432
|
+
const missing = fixture(undefined, undefined, undefined, {
|
|
433
|
+
status: 200,
|
|
434
|
+
body: null,
|
|
435
|
+
});
|
|
436
|
+
await expect(
|
|
437
|
+
missing.owner("apps.verifyBuild", {
|
|
438
|
+
jobId: source.build.jobId,
|
|
439
|
+
sourceRevision: source.build.sourceRevision,
|
|
440
|
+
}),
|
|
441
|
+
).rejects.toThrow("Build not found");
|
|
442
|
+
expect(missing.calls.select).toBeUndefined();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("apps.install remains Pending while another Workflow installation is in flight", async () => {
|
|
446
|
+
const f = fixture(undefined, undefined, undefined, undefined, {
|
|
447
|
+
status: 202,
|
|
448
|
+
body: { pending: true },
|
|
449
|
+
});
|
|
450
|
+
expect(
|
|
451
|
+
await f.owner("apps.install", {
|
|
452
|
+
jobId: "build_test",
|
|
453
|
+
installationId: "my-fork",
|
|
454
|
+
release: f.release,
|
|
455
|
+
}),
|
|
456
|
+
).toEqual({ kind: "pending", retryAfterMs: 5000 });
|
|
457
|
+
});
|
|
458
|
+
|
|
148
459
|
it("apps.install switches the Release through the APPS binding", async () => {
|
|
149
460
|
const f = fixture();
|
|
150
461
|
const result = await f.owner("apps.install", {
|
|
462
|
+
jobId: "build_test",
|
|
151
463
|
installationId: "my-fork",
|
|
152
464
|
release: f.release,
|
|
153
465
|
});
|
|
154
466
|
expect(result).toEqual({ installationId: "my-fork", status: "enabled" });
|
|
155
467
|
expect(f.calls.select).toEqual({
|
|
468
|
+
jobId: "build_test",
|
|
156
469
|
installationId: "my-fork",
|
|
157
470
|
release: f.release,
|
|
158
471
|
});
|
|
@@ -165,6 +478,29 @@ describe("apps.build / apps.install", () => {
|
|
|
165
478
|
expect(f.calls.buildStatus).toEqual({ jobId: "build_test" });
|
|
166
479
|
});
|
|
167
480
|
|
|
481
|
+
it("apps.buildStatus discovers a repository's automatic consent outcome without a browser job id", async () => {
|
|
482
|
+
const f = fixture(undefined, undefined, {
|
|
483
|
+
kind: "needs_consent",
|
|
484
|
+
installationId: "my-fork",
|
|
485
|
+
});
|
|
486
|
+
expect(
|
|
487
|
+
await f.owner("apps.buildStatus", { repo: "my-fork" }),
|
|
488
|
+
).toMatchObject({
|
|
489
|
+
jobId: "build_test",
|
|
490
|
+
status: "succeeded",
|
|
491
|
+
installation: { kind: "needs_consent", installationId: "my-fork" },
|
|
492
|
+
});
|
|
493
|
+
expect(f.calls.buildStatus).toEqual({ repo: "my-fork" });
|
|
494
|
+
expect(f.calls.select).toBeUndefined();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("apps.buildStatus reports no job as null rather than an invented build or failure", async () => {
|
|
498
|
+
const f = fixture();
|
|
499
|
+
expect(await f.owner("apps.buildStatus", { repo: "no-jobs" })).toBeNull();
|
|
500
|
+
expect(f.calls.buildStatus).toEqual({ repo: "no-jobs" });
|
|
501
|
+
expect(f.calls.select).toBeUndefined();
|
|
502
|
+
});
|
|
503
|
+
|
|
168
504
|
it("apps.buildStatus answers Pending while the job runs, so a Workflow step waits on it", async () => {
|
|
169
505
|
const f = fixture();
|
|
170
506
|
expect(appBackend.methods["apps.buildStatus"]?.policy.pending).toBe(true);
|
|
@@ -174,6 +510,28 @@ describe("apps.build / apps.install", () => {
|
|
|
174
510
|
expect(result).toMatchObject({ kind: "pending" });
|
|
175
511
|
});
|
|
176
512
|
|
|
513
|
+
it.each(["installed", "needs_consent"] as const)(
|
|
514
|
+
"apps.buildStatus preserves the typed %s outcome for Settings",
|
|
515
|
+
async (kind) => {
|
|
516
|
+
const installation = { kind, installationId: "my-fork" };
|
|
517
|
+
const f = fixture(undefined, undefined, installation);
|
|
518
|
+
expect(
|
|
519
|
+
await f.owner("apps.buildStatus", { jobId: "build_test" }),
|
|
520
|
+
).toMatchObject({ status: "succeeded", installation });
|
|
521
|
+
expect(f.calls.select).toBeUndefined();
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
it("apps.buildStatus stays Pending until automatic installation has an outcome", async () => {
|
|
526
|
+
const f = fixture(undefined, undefined, {
|
|
527
|
+
kind: "pending",
|
|
528
|
+
installationId: "my-fork",
|
|
529
|
+
});
|
|
530
|
+
expect(
|
|
531
|
+
await f.owner("apps.buildStatus", { jobId: "build_test" }),
|
|
532
|
+
).toMatchObject({ kind: "pending" });
|
|
533
|
+
});
|
|
534
|
+
|
|
177
535
|
it("declares what the host's APPS operations require, so their grants reach the call", () => {
|
|
178
536
|
for (const [method, operation] of [
|
|
179
537
|
["apps.build", "build"],
|
|
@@ -193,11 +551,23 @@ describe("apps.build / apps.install", () => {
|
|
|
193
551
|
}
|
|
194
552
|
});
|
|
195
553
|
|
|
554
|
+
it("admits registration through the host's APPS operation without Computer access", () => {
|
|
555
|
+
const declared = appBackendBindingOperation("APPS", "register");
|
|
556
|
+
const policy = appBackend.methods["apps.register"]?.policy;
|
|
557
|
+
expect(declared).toMatchObject({
|
|
558
|
+
requires: ["git.repository"],
|
|
559
|
+
effect: "write",
|
|
560
|
+
});
|
|
561
|
+
expect(policy).toBeDefined();
|
|
562
|
+
if (declared === undefined || policy === undefined) return;
|
|
563
|
+
expect(scopeOperationToMethod(declared, policy, "write")).toBe(true);
|
|
564
|
+
});
|
|
565
|
+
|
|
196
566
|
it("refuses apps.build for a non-owner member, typed", async () => {
|
|
197
567
|
const f = fixture();
|
|
198
568
|
await expect(
|
|
199
569
|
f.member("apps.build", {
|
|
200
|
-
appId: "
|
|
570
|
+
appId: "app_myfork",
|
|
201
571
|
sourceRevision: `git:${"a".repeat(40)}`,
|
|
202
572
|
}),
|
|
203
573
|
).rejects.toSatisfy(
|
|
@@ -211,6 +581,7 @@ describe("apps.build / apps.install", () => {
|
|
|
211
581
|
const f = fixture();
|
|
212
582
|
await expect(
|
|
213
583
|
f.member("apps.install", {
|
|
584
|
+
jobId: "build_test",
|
|
214
585
|
installationId: "my-fork",
|
|
215
586
|
release: f.release,
|
|
216
587
|
}),
|
|
@@ -240,6 +611,8 @@ describe("apps.fork", () => {
|
|
|
240
611
|
const refused = () => Promise.reject(new Error("Not part of a fork"));
|
|
241
612
|
const env = {
|
|
242
613
|
APPS: {
|
|
614
|
+
repository: refused,
|
|
615
|
+
register: refused,
|
|
243
616
|
fork: (input: { platformAppId: string; name: string }) => {
|
|
244
617
|
forks.push(input);
|
|
245
618
|
return Promise.resolve({
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
repositoryBrowserFixture,
|
|
3
|
+
repositoryBrowserPage,
|
|
4
|
+
startManualBuildWorkflow,
|
|
5
|
+
assertNoDirectInstall,
|
|
6
|
+
} from "./repository-browser.fixture.mjs";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import console from "node:console";
|
|
9
|
+
import { fileURLToPath, URL } from "node:url";
|
|
10
|
+
|
|
11
|
+
// Real Settings/button and Trellis cards, controlled App-method wire replies.
|
|
12
|
+
// This UI law does not claim a real host, Computer or installed Release.
|
|
13
|
+
const output = new URL(
|
|
14
|
+
"../../../../.botanical/honest-errors/manual-workflow-shots/",
|
|
15
|
+
import.meta.url,
|
|
16
|
+
);
|
|
17
|
+
const fixture = await repositoryBrowserFixture(output);
|
|
18
|
+
const { browser, origin } = fixture;
|
|
19
|
+
try {
|
|
20
|
+
for (const width of [1440, 390])
|
|
21
|
+
for (const theme of ["light", "dark"])
|
|
22
|
+
for (const refused of [false, true]) {
|
|
23
|
+
const { page, errors } = await repositoryBrowserPage(
|
|
24
|
+
browser,
|
|
25
|
+
width,
|
|
26
|
+
theme,
|
|
27
|
+
);
|
|
28
|
+
await page.goto(
|
|
29
|
+
origin +
|
|
30
|
+
"test/browser/repository-settings.html?scenario=" +
|
|
31
|
+
(refused ? "manual-workflow-refused" : "manual-workflow"),
|
|
32
|
+
);
|
|
33
|
+
await page.getByRole("link", { name: "Settings", exact: true }).click();
|
|
34
|
+
await page
|
|
35
|
+
.getByRole("button", { name: "Make this an App", exact: true })
|
|
36
|
+
.click();
|
|
37
|
+
await startManualBuildWorkflow(
|
|
38
|
+
page,
|
|
39
|
+
"git:" + "b".repeat(40),
|
|
40
|
+
"edited-installation",
|
|
41
|
+
);
|
|
42
|
+
await page
|
|
43
|
+
.getByText("Workflow run run_settings", { exact: true })
|
|
44
|
+
.waitFor();
|
|
45
|
+
if (refused) {
|
|
46
|
+
await page
|
|
47
|
+
.getByRole("alert")
|
|
48
|
+
.filter({
|
|
49
|
+
hasText: "No hosted build backend is attached to this Computer",
|
|
50
|
+
})
|
|
51
|
+
.waitFor();
|
|
52
|
+
assert.equal(
|
|
53
|
+
await page.getByText("Release installed", { exact: true }).count(),
|
|
54
|
+
0,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(
|
|
57
|
+
await page.getByText("Release verified", { exact: true }).count(),
|
|
58
|
+
0,
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
await page.getByText("Build running", { exact: true }).waitFor();
|
|
62
|
+
await page.getByText("Release verified", { exact: true }).waitFor();
|
|
63
|
+
await page.getByText("Release installed", { exact: true }).waitFor();
|
|
64
|
+
assert.equal(
|
|
65
|
+
await page.locator(".wf-step").count(),
|
|
66
|
+
3,
|
|
67
|
+
"the retained Trellis step cards render the actual step rows",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const calls = JSON.parse(
|
|
71
|
+
await page.locator("#app-calls").textContent(),
|
|
72
|
+
);
|
|
73
|
+
assert.equal(
|
|
74
|
+
calls.filter((name) => name === "apps.startBuildWorkflow").length,
|
|
75
|
+
1,
|
|
76
|
+
);
|
|
77
|
+
assert.ok(calls.includes("apps.buildWorkflowRun"));
|
|
78
|
+
assert.ok(
|
|
79
|
+
!calls.includes("apps.build"),
|
|
80
|
+
"the button must start the Workflow, not a separate native build",
|
|
81
|
+
);
|
|
82
|
+
assert.ok(
|
|
83
|
+
!calls.includes("apps.install"),
|
|
84
|
+
"the panel must never select a Release as an App",
|
|
85
|
+
);
|
|
86
|
+
await assertNoDirectInstall(page);
|
|
87
|
+
assert.deepEqual(errors, []);
|
|
88
|
+
await page.screenshot({
|
|
89
|
+
path: fileURLToPath(
|
|
90
|
+
new URL(
|
|
91
|
+
width + "-" + theme + (refused ? "-refused" : "") + ".png",
|
|
92
|
+
output,
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
fullPage: true,
|
|
96
|
+
});
|
|
97
|
+
await page.close();
|
|
98
|
+
}
|
|
99
|
+
console.log(
|
|
100
|
+
"PASS manual Build & install starts one Workflow, renders Trellis cards and preserves refusal reasons in four views",
|
|
101
|
+
);
|
|
102
|
+
} finally {
|
|
103
|
+
await fixture.close();
|
|
104
|
+
}
|