@fungi.computer/bonsai 0.0.1 → 0.2.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 +92 -0
- package/README.md +66 -12
- package/backend/build-workflow.ts +137 -0
- package/backend/index.ts +13 -7
- package/backend/methods.ts +222 -19
- package/package.json +54 -18
- package/scripts/release.mjs +12 -1
- package/scripts/shed-terminal-proof.mjs +30 -28
- 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/present.test.ts +27 -0
- 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/vitest.config.ts +43 -1
- package/web/app-build-panel.tsx +183 -95
- package/web/bonsai.css +1 -0
- package/web/change-page.tsx +10 -6
- package/web/index.html +0 -1
- package/web/repo-settings.tsx +1 -1
- package/web/public/favicon.svg +0 -1
package/shared/contract.ts
CHANGED
|
@@ -12,6 +12,15 @@ import { commit } from "@fungi.computer/agar/roles/files";
|
|
|
12
12
|
|
|
13
13
|
export const productName = "Bonsai";
|
|
14
14
|
|
|
15
|
+
/** An explicit owner action starts the same published build/install Workflow. */
|
|
16
|
+
export const appBuildWorkflowInput = z.strictObject({
|
|
17
|
+
repo: repoName,
|
|
18
|
+
sourceRevision: z.string().min(1).max(256),
|
|
19
|
+
installationId: z.string().min(1).max(256),
|
|
20
|
+
requestId: z.string().min(1).max(256),
|
|
21
|
+
});
|
|
22
|
+
export type AppBuildWorkflowInput = z.infer<typeof appBuildWorkflowInput>;
|
|
23
|
+
|
|
15
24
|
/** Any JSON value: App method inputs and results travel as JSON. */
|
|
16
25
|
export const jsonValue = z.json();
|
|
17
26
|
export type JsonValue = z.infer<typeof jsonValue>;
|
|
@@ -389,6 +398,22 @@ export const issueFiles = z.strictObject({
|
|
|
389
398
|
export type IssueFiles = z.infer<typeof issueFiles>;
|
|
390
399
|
export const repoPage = z.strictObject({ repos: z.array(repoSummary) });
|
|
391
400
|
|
|
401
|
+
/** The host's install decision on this build, not a consent request. */
|
|
402
|
+
const appBuildInstallation = z.discriminatedUnion("kind", [
|
|
403
|
+
z.strictObject({ kind: z.literal("pending"), installationId: z.string() }),
|
|
404
|
+
z.strictObject({ kind: z.literal("installed"), installationId: z.string() }),
|
|
405
|
+
z.strictObject({
|
|
406
|
+
kind: z.literal("needs_consent"),
|
|
407
|
+
installationId: z.string(),
|
|
408
|
+
}),
|
|
409
|
+
z.strictObject({
|
|
410
|
+
kind: z.literal("failed"),
|
|
411
|
+
installationId: z.string(),
|
|
412
|
+
detail: z.string(),
|
|
413
|
+
}),
|
|
414
|
+
]);
|
|
415
|
+
export type AppBuildInstallation = z.infer<typeof appBuildInstallation>;
|
|
416
|
+
|
|
392
417
|
/** One App build job as its owner sees it; a settled success carries its Release. */
|
|
393
418
|
export const appBuildView = z.strictObject({
|
|
394
419
|
jobId: z.string().min(1).max(256),
|
|
@@ -399,6 +424,22 @@ export const appBuildView = z.strictObject({
|
|
|
399
424
|
detail: z.string().max(65_536).nullable(),
|
|
400
425
|
/** The recorded Release, parsed by its owner where it is installed. */
|
|
401
426
|
release: z.unknown(),
|
|
427
|
+
installation: appBuildInstallation.nullable(),
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/** Register the calling installation's repository, never a caller-supplied recipe. */
|
|
431
|
+
export const appRegistrationInput = z.strictObject({ repo: repoName });
|
|
432
|
+
|
|
433
|
+
/** Workflows wait for one job; Settings discovers its repository's latest job. */
|
|
434
|
+
export const appBuildStatusInput = z.union([
|
|
435
|
+
z.strictObject({ jobId: z.string().min(1).max(256) }),
|
|
436
|
+
appRegistrationInput,
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
/** The host's App identity and exact main commit whose metadata it registered. */
|
|
440
|
+
export const appRegistration = appBuildView.pick({
|
|
441
|
+
appId: true,
|
|
442
|
+
sourceRevision: true,
|
|
402
443
|
});
|
|
403
444
|
|
|
404
445
|
export const errorBody = z.strictObject({ error: z.string() });
|
|
@@ -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({
|