@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
|
@@ -198,14 +198,11 @@ test("runs tests on the pinned Change head even after the source branch moves",
|
|
|
198
198
|
.object({ status: z.enum(["passed", "failed"]), summary: z.string() })
|
|
199
199
|
.parse(
|
|
200
200
|
await evaluate(resultNode.source, {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
stderr: execution.stderr,
|
|
207
|
-
truncated: false,
|
|
208
|
-
},
|
|
201
|
+
exitCode: execution.status,
|
|
202
|
+
stdout: execution.stdout,
|
|
203
|
+
stderr: execution.stderr,
|
|
204
|
+
stdoutTruncated: false,
|
|
205
|
+
stderrTruncated: false,
|
|
209
206
|
}),
|
|
210
207
|
);
|
|
211
208
|
assert.equal(result.status, expected);
|
|
@@ -224,11 +221,16 @@ test("runs tests on the pinned Change head even after the source branch moves",
|
|
|
224
221
|
);
|
|
225
222
|
for (const refusal of [
|
|
226
223
|
{ error: "not_granted" },
|
|
227
|
-
{
|
|
224
|
+
{ error: "output_unavailable" },
|
|
225
|
+
{ error: "attachment_budget_exceeded", kind: "exec", retryAfterMs: 1500 },
|
|
228
226
|
]) {
|
|
229
227
|
const result = z
|
|
230
|
-
.object({
|
|
228
|
+
.object({
|
|
229
|
+
status: z.enum(["passed", "failed"]),
|
|
230
|
+
summary: z.string(),
|
|
231
|
+
})
|
|
231
232
|
.parse(await evaluate(resultNode.source, refusal));
|
|
232
233
|
assert.equal(result.status, "failed");
|
|
234
|
+
assert.equal(result.summary, refusal.error);
|
|
233
235
|
}
|
|
234
236
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { URL, fileURLToPath } from "node:url";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { build } from "vite";
|
|
8
|
+
import { fungiFaviconHref } from "@fungi.computer/sigils/favicon";
|
|
9
|
+
|
|
10
|
+
test("the built Bonsai page carries the Fungi sigil as its tab icon", async (t) => {
|
|
11
|
+
const directory = await mkdtemp(join(tmpdir(), "bonsai-favicon-"));
|
|
12
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
13
|
+
await build({
|
|
14
|
+
configFile: fileURLToPath(new URL("../vite.config.ts", import.meta.url)),
|
|
15
|
+
build: { outDir: directory, emptyOutDir: true },
|
|
16
|
+
logLevel: "silent",
|
|
17
|
+
});
|
|
18
|
+
const html = await readFile(join(directory, "index.html"), "utf8");
|
|
19
|
+
assert.ok(
|
|
20
|
+
html.includes(
|
|
21
|
+
`<link rel="icon" type="image/svg+xml" href="${fungiFaviconHref}">`,
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
assert.doesNotMatch(html, /href="[^"]*favicon\.svg"/u);
|
|
25
|
+
});
|
package/test/git-push.native.mjs
CHANGED
|
@@ -404,6 +404,36 @@ try {
|
|
|
404
404
|
0,
|
|
405
405
|
"accepted native retry publishes refs and all warm views",
|
|
406
406
|
);
|
|
407
|
+
// The actual App lifecycle method, not a helper invocation or an App event
|
|
408
|
+
// invented by a guest, must hand the accepted main head to Workflows.
|
|
409
|
+
const workflowSeed = await globalThis.fetch(origin + "/seed?probe=workflow");
|
|
410
|
+
assert.equal(workflowSeed.status, 204, await workflowSeed.text());
|
|
411
|
+
const workflowPush = await globalThis.fetch(origin + "/?probe=workflow", {
|
|
412
|
+
method: "POST",
|
|
413
|
+
body: JSON.stringify({
|
|
414
|
+
...fact,
|
|
415
|
+
repository: "pond",
|
|
416
|
+
eventId: "registered-main-push",
|
|
417
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "levi" } },
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
assert.equal(workflowPush.status, 200, await workflowPush.text());
|
|
421
|
+
const workflowStarts = await globalThis
|
|
422
|
+
.fetch(origin + "/workflow-starts?probe=workflow")
|
|
423
|
+
.then((response) => response.json());
|
|
424
|
+
assert.equal(
|
|
425
|
+
workflowStarts.length,
|
|
426
|
+
1,
|
|
427
|
+
"accepted main must start the build Workflow, not native installation",
|
|
428
|
+
);
|
|
429
|
+
assert.equal(workflowStarts[0].definition.title, "Build on merge");
|
|
430
|
+
const steps = workflowStarts[0].definition.nodes.map((node) => node.id);
|
|
431
|
+
assert.ok(steps.indexOf("build") >= 0);
|
|
432
|
+
assert.ok(steps.indexOf("verify") > steps.indexOf("build"));
|
|
433
|
+
assert.ok(steps.indexOf("install") > steps.indexOf("verify"));
|
|
434
|
+
assert.equal(workflowStarts[0].input.sourceRevision, "git:" + "b".repeat(40));
|
|
435
|
+
assert.equal(workflowStarts[0].input.appId, "app_pond");
|
|
436
|
+
assert.equal(workflowStarts[0].bindings.bonsai, "bonsai");
|
|
407
437
|
log(
|
|
408
438
|
"PASS: Bonsai warm read-copy Git calls=0, T2 pending/exhaustion/foreground pages PASS, native push events, parsed Git insights, caller-isolated layouts persisted across restart",
|
|
409
439
|
);
|
package/test/git-push.worker.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as S from "effect/Schema";
|
|
2
|
+
import { repositoryWorkflowSourceSchema } from "@fungi.computer/workflows-wire";
|
|
1
3
|
import {
|
|
2
4
|
ReadCopyBudgetExceeded,
|
|
3
5
|
countingGit,
|
|
@@ -8,7 +10,7 @@ import {
|
|
|
8
10
|
type AppGitPushFact,
|
|
9
11
|
} from "@fungi.computer/agar/git";
|
|
10
12
|
import type { StandardWebhookPayload } from "@fungi.computer/oyster/git";
|
|
11
|
-
import { BonsaiApp } from "../backend/index.js";
|
|
13
|
+
import { BonsaiApp, type BonsaiEnv } from "../backend/index.js";
|
|
12
14
|
import type { GitBinding } from "../backend/git.js";
|
|
13
15
|
import type { HookEvent } from "../shared/hooks.js";
|
|
14
16
|
|
|
@@ -128,6 +130,7 @@ function growingGit(large: () => boolean): GitBinding {
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export class BonsaiPushProbe extends BonsaiApp {
|
|
133
|
+
private readonly workflowRequests: unknown[];
|
|
131
134
|
private readonly providerCalls: () => number;
|
|
132
135
|
private readonly delivery: { failNext: boolean };
|
|
133
136
|
private readonly emitted: {
|
|
@@ -162,8 +165,44 @@ export class BonsaiPushProbe extends BonsaiApp {
|
|
|
162
165
|
limit: 10_000,
|
|
163
166
|
signal: lifetime.signal,
|
|
164
167
|
});
|
|
165
|
-
|
|
168
|
+
const workflowRequests: unknown[] = [];
|
|
169
|
+
const unsupported = () =>
|
|
170
|
+
Promise.reject(
|
|
171
|
+
new Error("Unexpected APPS operation in lifecycle handoff proof"),
|
|
172
|
+
);
|
|
173
|
+
const bindings: BonsaiEnv = {
|
|
166
174
|
GIT: counted.git,
|
|
175
|
+
APPS: {
|
|
176
|
+
register: unsupported,
|
|
177
|
+
fork: unsupported,
|
|
178
|
+
build: unsupported,
|
|
179
|
+
buildStatus: unsupported,
|
|
180
|
+
select: unsupported,
|
|
181
|
+
// Only pond is already registered. Reading this source must not build.
|
|
182
|
+
repository: ({ repo }: { repo: string }) =>
|
|
183
|
+
Promise.resolve(
|
|
184
|
+
repo === "pond"
|
|
185
|
+
? S.decodeUnknownSync(repositoryWorkflowSourceSchema)({
|
|
186
|
+
appId: "app_pond",
|
|
187
|
+
installationId: "bonsai",
|
|
188
|
+
callerAppId: "app_bonsai",
|
|
189
|
+
callerSourceRevision: "git:" + "a".repeat(40),
|
|
190
|
+
})
|
|
191
|
+
: null,
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
WORKFLOWS: {
|
|
195
|
+
runs: unused,
|
|
196
|
+
graphStatus: unused,
|
|
197
|
+
runGraph: (input) => {
|
|
198
|
+
workflowRequests.push(input);
|
|
199
|
+
return Promise.resolve({
|
|
200
|
+
kind: "started",
|
|
201
|
+
runId: "run_main",
|
|
202
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
},
|
|
167
206
|
EVENTS: {
|
|
168
207
|
emit: (type, payload, eventId) => {
|
|
169
208
|
if (delivery.failNext) {
|
|
@@ -174,7 +213,9 @@ export class BonsaiPushProbe extends BonsaiApp {
|
|
|
174
213
|
return Promise.resolve();
|
|
175
214
|
},
|
|
176
215
|
},
|
|
177
|
-
}
|
|
216
|
+
};
|
|
217
|
+
super(ctx, bindings);
|
|
218
|
+
this.workflowRequests = workflowRequests;
|
|
178
219
|
this.providerCalls = counted.total;
|
|
179
220
|
this.emitted = emitted;
|
|
180
221
|
this.delivery = delivery;
|
|
@@ -224,6 +265,9 @@ export class BonsaiPushProbe extends BonsaiApp {
|
|
|
224
265
|
};
|
|
225
266
|
}
|
|
226
267
|
}
|
|
268
|
+
workflowStarts(): Promise<string> {
|
|
269
|
+
return Promise.resolve(JSON.stringify(this.workflowRequests));
|
|
270
|
+
}
|
|
227
271
|
calls(): Promise<number> {
|
|
228
272
|
return Promise.resolve(this.providerCalls());
|
|
229
273
|
}
|
|
@@ -252,6 +296,10 @@ export default {
|
|
|
252
296
|
): Promise<Response> {
|
|
253
297
|
const url = new URL(request.url);
|
|
254
298
|
const probe = env.PROBE.getByName(url.searchParams.get("probe") ?? "push");
|
|
299
|
+
if (url.pathname === "/workflow-starts")
|
|
300
|
+
return new Response(await probe.workflowStarts(), {
|
|
301
|
+
headers: { "Content-Type": "application/json" },
|
|
302
|
+
});
|
|
255
303
|
if (url.pathname === "/grow") return Response.json(await probe.grow());
|
|
256
304
|
if (url.pathname === "/calls")
|
|
257
305
|
return Response.json({ calls: await probe.calls() });
|
|
@@ -1,234 +1,77 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
1
2
|
import assert from "node:assert/strict";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { test } from "node:test";
|
|
4
|
-
import { URL } from "node:url";
|
|
5
|
-
import { Script } from "node:vm";
|
|
6
5
|
import * as S from "effect/Schema";
|
|
7
|
-
import * as z from "zod";
|
|
8
6
|
import {
|
|
9
|
-
catalogEvents,
|
|
10
7
|
catalogMethods,
|
|
11
8
|
compile,
|
|
12
|
-
triggerRun,
|
|
13
9
|
validate,
|
|
14
10
|
workflowDefinitionSchema,
|
|
15
11
|
} from "@fungi.computer/braid";
|
|
16
12
|
import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
|
|
17
|
-
import {
|
|
18
|
-
import { appBackend as bonsaiBackend } from "../backend/methods.ts";
|
|
19
|
-
const bonsaiApi = buildAppApi(bonsaiBackend);
|
|
13
|
+
import { appBackend } from "../backend/methods.ts";
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
await readFile(
|
|
28
|
-
new URL(
|
|
29
|
-
"../../trellis/templates/bonsai-build-on-merge.json",
|
|
30
|
-
import.meta.url,
|
|
31
|
-
),
|
|
32
|
-
"utf8",
|
|
33
|
-
),
|
|
15
|
+
async function template() {
|
|
16
|
+
const file = JSON.parse(
|
|
17
|
+
await readFile(
|
|
18
|
+
new URL(
|
|
19
|
+
"../../trellis/templates/bonsai-build-on-merge.json",
|
|
20
|
+
import.meta.url,
|
|
34
21
|
),
|
|
35
|
-
|
|
36
|
-
|
|
22
|
+
"utf8",
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
return S.decodeUnknownSync(workflowDefinitionSchema)(file.definition);
|
|
37
26
|
}
|
|
38
27
|
|
|
39
|
-
async
|
|
40
|
-
|
|
28
|
+
test("the lifecycle build template compiles against Bonsai's maintained method declarations", async () => {
|
|
29
|
+
const definition = await template();
|
|
30
|
+
const catalog = {
|
|
41
31
|
app_bonsai: {
|
|
42
|
-
methods: (await catalogMethods(
|
|
43
|
-
events: (await catalogEvents(bonsaiApi.events)).events,
|
|
32
|
+
methods: (await catalogMethods(buildAppApi(appBackend).methods)).methods,
|
|
44
33
|
},
|
|
45
34
|
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function findNode(definition, id) {
|
|
49
|
-
const node = definition.nodes.find((candidate) => candidate.id === id);
|
|
50
|
-
assert.ok(node, `missing node ${id}`);
|
|
51
|
-
return node;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Only the external Code execution boundary is replaced by Node VM here.
|
|
55
|
-
const evaluate = async (code, input) =>
|
|
56
|
-
z.json().parse(
|
|
57
|
-
await new Script(`(async () => { ${code} })()`).runInNewContext({
|
|
58
|
-
env: { INPUT: { read: () => Promise.resolve(input) } },
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
const after = "c".repeat(40);
|
|
63
|
-
// The envelope Bonsai's outbox emits when a Change merges into `repo`.
|
|
64
|
-
function mergedEnvelope({ repo = "my-app", ref = "main" } = {}) {
|
|
65
|
-
return activityEnvelope(
|
|
66
|
-
{
|
|
67
|
-
id: 41,
|
|
68
|
-
at: Date.UTC(2026, 9, 15, 4),
|
|
69
|
-
type: "merge",
|
|
70
|
-
repo,
|
|
71
|
-
actor: "morel",
|
|
72
|
-
data: {
|
|
73
|
-
change: {
|
|
74
|
-
repo,
|
|
75
|
-
number: 3,
|
|
76
|
-
sourceRepo: `${repo}--morel`,
|
|
77
|
-
sourceRef: "edit",
|
|
78
|
-
targetRef: ref,
|
|
79
|
-
head: "b".repeat(40),
|
|
80
|
-
},
|
|
81
|
-
from: `${repo}--morel`,
|
|
82
|
-
ref,
|
|
83
|
-
before: "a".repeat(40),
|
|
84
|
-
after,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: `${repo}--morel`,
|
|
89
|
-
canonical: repo,
|
|
90
|
-
cloneUrl: `https://bonsai.example/git/${repo}--morel.git`,
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
test("pins the build-on-merge template to Bonsai's maintained declarations", async () => {
|
|
96
|
-
const definition = await buildOnMergeTemplate();
|
|
97
|
-
const catalog = await installedCatalog();
|
|
98
35
|
assert.deepEqual(validate(definition, catalog), []);
|
|
99
36
|
const compiled = await compile(definition, catalog, {
|
|
100
37
|
bonsai: { appId: "app_bonsai", installationId: "bonsai" },
|
|
101
38
|
});
|
|
102
39
|
assert.ok(compiled.ok, JSON.stringify(compiled));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
assert.doesNotMatch(text, /gitAccess|OYSTER_OWNER_KEY|Authorization|token/iu);
|
|
107
|
-
// The run starts the build of the merged commit, then waits on that job.
|
|
108
|
-
const build = findNode(definition, "build");
|
|
109
|
-
assert.ok(build.kind === "app");
|
|
110
|
-
assert.equal(build.method, "apps.build");
|
|
111
|
-
assert.deepEqual(build.values.sourceRevision, {
|
|
112
|
-
from: { node: "merged", port: "sourceRevision" },
|
|
113
|
-
});
|
|
114
|
-
const status = findNode(definition, "build-status");
|
|
115
|
-
assert.ok(status.kind === "app");
|
|
116
|
-
assert.equal(status.method, "apps.buildStatus");
|
|
117
|
-
assert.deepEqual(status.values.jobId, {
|
|
118
|
-
from: { node: "build", port: "jobId" },
|
|
119
|
-
});
|
|
120
|
-
assert.equal(
|
|
121
|
-
catalog.app_bonsai.methods["apps.buildStatus"].pending,
|
|
122
|
-
true,
|
|
123
|
-
"the Workflow waits on the job through a pending method",
|
|
40
|
+
assert.doesNotMatch(
|
|
41
|
+
JSON.stringify(definition),
|
|
42
|
+
/gitAccess|OYSTER_OWNER_KEY|Authorization|token/iu,
|
|
124
43
|
);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("starts on a merge into the App's source main, at the merged commit", async () => {
|
|
128
|
-
const definition = await buildOnMergeTemplate();
|
|
129
|
-
const trigger = definition.triggers?.find(
|
|
130
|
-
(item) => item.on === "event" && item.event === "change.merged",
|
|
131
|
-
);
|
|
132
|
-
assert.ok(trigger);
|
|
133
|
-
const payload = mergedEnvelope();
|
|
134
|
-
const outcome = triggerRun(definition, trigger.id, {
|
|
135
|
-
eventId: payload.data.eventId,
|
|
136
|
-
payload,
|
|
137
|
-
});
|
|
138
|
-
assert.equal(outcome.kind, "start", JSON.stringify(outcome));
|
|
139
|
-
// The owner setting defaults to showing the Workflows review UI.
|
|
140
|
-
assert.equal(outcome.input.reviewBeforeInstall, true);
|
|
141
|
-
const merged = findNode(definition, "merged");
|
|
142
|
-
assert.ok(merged.kind === "code");
|
|
143
44
|
assert.deepEqual(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
{ appId: outcome.input.appId, sourceRevision: `git:${after}` },
|
|
45
|
+
definition.nodes.map((node) =>
|
|
46
|
+
node.kind === "app" ? node.method : node.kind,
|
|
47
|
+
),
|
|
48
|
+
["apps.build", "apps.verifyBuild", "apps.install"],
|
|
149
49
|
);
|
|
150
|
-
|
|
151
|
-
const skipped = mergedEnvelope(other);
|
|
152
|
-
assert.deepEqual(
|
|
153
|
-
triggerRun(definition, trigger.id, {
|
|
154
|
-
eventId: skipped.data.eventId,
|
|
155
|
-
payload: skipped,
|
|
156
|
-
}),
|
|
157
|
-
{ kind: "ignored" },
|
|
158
|
-
JSON.stringify(other),
|
|
159
|
-
);
|
|
160
|
-
}
|
|
50
|
+
assert.equal(catalog.app_bonsai.methods["apps.verifyBuild"].pending, true);
|
|
161
51
|
});
|
|
162
52
|
|
|
163
|
-
test("
|
|
164
|
-
const definition = await
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
artifact: { provenance: { sourceRevision } },
|
|
172
|
-
};
|
|
173
|
-
assert.deepEqual(
|
|
174
|
-
await evaluate(built.source, {
|
|
175
|
-
status: "succeeded",
|
|
176
|
-
detail: null,
|
|
177
|
-
release,
|
|
178
|
-
sourceRevision,
|
|
179
|
-
}),
|
|
180
|
-
{
|
|
181
|
-
release,
|
|
182
|
-
summary: `Built rel_built of app_myapp from ${sourceRevision}`,
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
await assert.rejects(
|
|
186
|
-
evaluate(built.source, {
|
|
187
|
-
status: "failed",
|
|
188
|
-
detail: "tsc exited 2",
|
|
189
|
-
release: null,
|
|
190
|
-
sourceRevision,
|
|
191
|
-
}),
|
|
192
|
-
/The build did not pass: tsc exited 2/u,
|
|
193
|
-
);
|
|
194
|
-
await assert.rejects(
|
|
195
|
-
evaluate(built.source, {
|
|
196
|
-
status: "succeeded",
|
|
197
|
-
detail: null,
|
|
198
|
-
release,
|
|
199
|
-
sourceRevision: `git:${"d".repeat(40)}`,
|
|
200
|
-
}),
|
|
201
|
-
/not the merged commit/u,
|
|
202
|
-
);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("routes the Release through review only when Review before install is on", async () => {
|
|
206
|
-
const definition = await buildOnMergeTemplate();
|
|
207
|
-
const gate = findNode(definition, "review-gate");
|
|
208
|
-
assert.ok(gate.kind === "condition");
|
|
209
|
-
assert.deepEqual(gate.test, {
|
|
210
|
-
op: "eq",
|
|
211
|
-
left: { from: { node: "$input", port: "reviewBeforeInstall" } },
|
|
212
|
-
right: { literal: true },
|
|
213
|
-
});
|
|
214
|
-
// The review is reached only from the gate's true outlet, automatic
|
|
215
|
-
// install only from its false one; install takes whichever ran.
|
|
216
|
-
const review = findNode(definition, "review");
|
|
217
|
-
assert.ok(review.kind === "review");
|
|
218
|
-
assert.deepEqual(review.show[0], { node: "to-review", port: "release" });
|
|
219
|
-
assert.deepEqual(findNode(definition, "to-review").fields.review, {
|
|
220
|
-
from: { node: "review-gate", port: "true" },
|
|
53
|
+
test("verification gates installation and the template neither configures nor promises review", async () => {
|
|
54
|
+
const definition = await template();
|
|
55
|
+
const verify = definition.nodes.find((node) => node.id === "verify");
|
|
56
|
+
const install = definition.nodes.find((node) => node.id === "install");
|
|
57
|
+
assert.ok(verify?.kind === "app");
|
|
58
|
+
assert.ok(install?.kind === "app");
|
|
59
|
+
assert.deepEqual(verify.values.jobId, {
|
|
60
|
+
from: { node: "build", port: "jobId" },
|
|
221
61
|
});
|
|
222
|
-
assert.deepEqual(
|
|
223
|
-
from: { node: "
|
|
62
|
+
assert.deepEqual(verify.values.sourceRevision, {
|
|
63
|
+
from: { node: "$input", port: "sourceRevision" },
|
|
224
64
|
});
|
|
225
|
-
const install = findNode(definition, "install");
|
|
226
|
-
assert.ok(install.kind === "app");
|
|
227
|
-
assert.equal(install.method, "apps.install");
|
|
228
65
|
assert.deepEqual(install.values.release, {
|
|
229
|
-
|
|
230
|
-
{ from: { node: "review", port: "approved" } },
|
|
231
|
-
{ from: { node: "automatic", port: "release" } },
|
|
232
|
-
],
|
|
66
|
+
from: { node: "verify", port: "release" },
|
|
233
67
|
});
|
|
68
|
+
assert.doesNotMatch(
|
|
69
|
+
JSON.stringify(definition),
|
|
70
|
+
/reviewBeforeInstall|review-gate|review before install|"kind":"review"/iu,
|
|
71
|
+
);
|
|
72
|
+
assert.equal(
|
|
73
|
+
definition.triggers,
|
|
74
|
+
undefined,
|
|
75
|
+
"the host lifecycle starts this template through WORKFLOWS, not a guest merge event",
|
|
76
|
+
);
|
|
234
77
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { appBackend } from "../backend/methods.js";
|
|
3
|
+
|
|
4
|
+
const repo = (name: string) => ({
|
|
5
|
+
name,
|
|
6
|
+
description: null,
|
|
7
|
+
parent: null,
|
|
8
|
+
owner: null,
|
|
9
|
+
createdAt: 1,
|
|
10
|
+
lastPushAt: null,
|
|
11
|
+
forks: [],
|
|
12
|
+
openChanges: 0,
|
|
13
|
+
activity: [],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("reads a repository listing as a count and the first few names", () => {
|
|
17
|
+
const present = appBackend.methods["repos.list"]?.present;
|
|
18
|
+
expect(present?.({}, { repos: [repo("storyboard")] })).toEqual({
|
|
19
|
+
id: "repos.list",
|
|
20
|
+
title: "Bonsai listed 1 repository",
|
|
21
|
+
detail: "storyboard",
|
|
22
|
+
});
|
|
23
|
+
expect(present?.({}, { repos: [repo("a"), repo("b")] })).toMatchObject({
|
|
24
|
+
title: "Bonsai listed 2 repositories",
|
|
25
|
+
detail: "a · b",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -2,45 +2,51 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import { execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import { once } from "node:events";
|
|
4
4
|
import { createServer } from "node:https";
|
|
5
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
5
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { URL } from "node:url";
|
|
10
10
|
|
|
11
|
+
async function createCertificate() {
|
|
12
|
+
const certDir = await mkdtemp(join(tmpdir(), "storyboard-save-tls-"));
|
|
13
|
+
try {
|
|
14
|
+
const keyPath = join(certDir, "key.pem");
|
|
15
|
+
const certPath = join(certDir, "cert.pem");
|
|
16
|
+
execFileSync(
|
|
17
|
+
"openssl",
|
|
18
|
+
[
|
|
19
|
+
"req",
|
|
20
|
+
"-x509",
|
|
21
|
+
"-newkey",
|
|
22
|
+
"rsa:2048",
|
|
23
|
+
"-keyout",
|
|
24
|
+
keyPath,
|
|
25
|
+
"-out",
|
|
26
|
+
certPath,
|
|
27
|
+
"-days",
|
|
28
|
+
"1",
|
|
29
|
+
"-nodes",
|
|
30
|
+
"-subj",
|
|
31
|
+
"/CN=127.0.0.1",
|
|
32
|
+
],
|
|
33
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
34
|
+
);
|
|
35
|
+
return { key: await readFile(keyPath), cert: await readFile(certPath) };
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(certDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* A real git smart-HTTP server over real TLS (self-signed), proxying to the
|
|
13
43
|
* maintained `git http-backend` CGI program against a directory of real bare
|
|
14
|
-
* repositories.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* rather than a stand-in local path.
|
|
44
|
+
* repositories. The host-supplied $FUNGI_GIT_REMOTE is an `https:` remote;
|
|
45
|
+
* this gives Shed's real `run` handler and git's real client something
|
|
46
|
+
* genuine to fetch and push against, rather than a stand-in local path.
|
|
18
47
|
*/
|
|
19
48
|
export async function startHttpsGitServer(reposRoot) {
|
|
20
|
-
const
|
|
21
|
-
const keyPath = join(certDir, "key.pem");
|
|
22
|
-
const certPath = join(certDir, "cert.pem");
|
|
23
|
-
execFileSync(
|
|
24
|
-
"openssl",
|
|
25
|
-
[
|
|
26
|
-
"req",
|
|
27
|
-
"-x509",
|
|
28
|
-
"-newkey",
|
|
29
|
-
"rsa:2048",
|
|
30
|
-
"-keyout",
|
|
31
|
-
keyPath,
|
|
32
|
-
"-out",
|
|
33
|
-
certPath,
|
|
34
|
-
"-days",
|
|
35
|
-
"1",
|
|
36
|
-
"-nodes",
|
|
37
|
-
"-subj",
|
|
38
|
-
"/CN=127.0.0.1",
|
|
39
|
-
],
|
|
40
|
-
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
41
|
-
);
|
|
42
|
-
const key = await readFile(keyPath);
|
|
43
|
-
const cert = await readFile(certPath);
|
|
49
|
+
const { key, cert } = await createCertificate();
|
|
44
50
|
function cgi(request, response) {
|
|
45
51
|
const url = new URL(request.url, "https://localhost");
|
|
46
52
|
const child = spawn("git", ["http-backend"], {
|