@fungi.computer/bonsai 0.1.0 → 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.
@@ -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: "app_my-fork",
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: "app_my-fork",
81
+ appId: "app_myfork",
65
82
  releaseId: "rel_test",
66
83
  artifact: {
67
- artifactRef: "artifact:root",
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
- const env = {
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
- status: 200,
87
- body:
88
- input.jobId === "build_running"
89
- ? { ...build, jobId: input.jobId }
90
- : { ...build, status: "succeeded" as const, release },
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: { installationId: string; release: unknown }) => {
180
+ select: (input: {
181
+ jobId: string;
182
+ installationId: string;
183
+ release: unknown;
184
+ }) => {
94
185
  calls.select = input;
95
- return Promise.resolve({
96
- status: 200,
97
- body: { installationId: input.installationId, status: "enabled" },
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 { owner, member, calls, build, release };
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("apps.build / apps.install", () => {
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: "app_my-fork",
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: "app_my-fork",
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: "app_my-fork",
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
+ }