@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "vite";
|
|
4
|
+
import { chromium } from "playwright";
|
|
5
|
+
import viteConfig from "./vite.config.mjs";
|
|
6
|
+
|
|
7
|
+
/** Serve the real Settings view and its controlled App-method transport. */
|
|
8
|
+
export async function repositoryBrowserFixture(output) {
|
|
9
|
+
await mkdir(output, { recursive: true });
|
|
10
|
+
const server = await createServer({
|
|
11
|
+
...viteConfig,
|
|
12
|
+
configFile: false,
|
|
13
|
+
server: { ...viteConfig.server, port: 0 },
|
|
14
|
+
});
|
|
15
|
+
await server.listen();
|
|
16
|
+
try {
|
|
17
|
+
const browser = await chromium.launch({ headless: true });
|
|
18
|
+
return {
|
|
19
|
+
browser,
|
|
20
|
+
origin: server.resolvedUrls.local[0],
|
|
21
|
+
async close() {
|
|
22
|
+
try {
|
|
23
|
+
await browser.close();
|
|
24
|
+
} finally {
|
|
25
|
+
await server.close();
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
await server.close();
|
|
31
|
+
throw cause;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function repositoryBrowserPage(browser, width, theme) {
|
|
36
|
+
const page = await browser.newPage({
|
|
37
|
+
viewport: { width, height: 1000 },
|
|
38
|
+
colorScheme: theme,
|
|
39
|
+
});
|
|
40
|
+
page.setDefaultTimeout(10000);
|
|
41
|
+
const errors = [];
|
|
42
|
+
page.on("pageerror", (cause) => errors.push(cause.message));
|
|
43
|
+
return { page, errors };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Expected identity stays with each scenario; inspect the same visible fields. */
|
|
47
|
+
export async function assertAuthoringFields(page, expected) {
|
|
48
|
+
for (const [name, value] of [
|
|
49
|
+
["App id", expected.appId],
|
|
50
|
+
["Source revision", expected.sourceRevision],
|
|
51
|
+
["Install at", expected.installationId],
|
|
52
|
+
]) {
|
|
53
|
+
assert.equal(
|
|
54
|
+
await page.getByRole("textbox", { name, exact: true }).inputValue(),
|
|
55
|
+
value,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function startManualBuildWorkflow(
|
|
61
|
+
page,
|
|
62
|
+
sourceRevision,
|
|
63
|
+
installationId,
|
|
64
|
+
) {
|
|
65
|
+
await page
|
|
66
|
+
.getByRole("textbox", { name: "Source revision", exact: true })
|
|
67
|
+
.fill(sourceRevision);
|
|
68
|
+
await page
|
|
69
|
+
.getByRole("textbox", { name: "Install at", exact: true })
|
|
70
|
+
.fill(installationId);
|
|
71
|
+
await page
|
|
72
|
+
.getByRole("button", { name: "Build & install", exact: true })
|
|
73
|
+
.click();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function assertNoDirectInstall(page) {
|
|
77
|
+
assert.equal(
|
|
78
|
+
await page
|
|
79
|
+
.getByRole("button", { name: "Install this Release", exact: true })
|
|
80
|
+
.count(),
|
|
81
|
+
0,
|
|
82
|
+
"Only the Workflow may install; no direct install action remains",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
-
import { createCapabilityRegistry } from "@fungi.computer/agar";
|
|
5
|
+
import { createCapabilityRegistry, pending } from "@fungi.computer/agar";
|
|
6
6
|
import { appMethods } from "@fungi.computer/agar/backend";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
-
import { repoDetail } from "../../shared/contract.js";
|
|
8
|
+
import { appBuildStatusInput, repoDetail } from "../../shared/contract.js";
|
|
9
9
|
import { connect } from "../../web/transport.js";
|
|
10
10
|
import { RouterProvider, useRouter } from "../../web/router.js";
|
|
11
11
|
import { RepoPage } from "../../web/pages.js";
|
|
@@ -39,10 +39,203 @@ client.setQueryData(["events", name], { events: [] });
|
|
|
39
39
|
client.setQueryData(["changes", name], { changes: [] });
|
|
40
40
|
client.setQueryData(["agents"], { agents: [] });
|
|
41
41
|
let attempts = 0;
|
|
42
|
+
let registrations = 0;
|
|
42
43
|
let withdrawn = false;
|
|
44
|
+
const appCalls: string[] = [];
|
|
45
|
+
const build = {
|
|
46
|
+
jobId: "build_settings",
|
|
47
|
+
appId: "app_settingstest",
|
|
48
|
+
releaseId: "rel_settings",
|
|
49
|
+
sourceRevision: "git:" + "a".repeat(40),
|
|
50
|
+
status: "running",
|
|
51
|
+
detail: null,
|
|
52
|
+
release: null,
|
|
53
|
+
installation: { kind: "pending", installationId: name },
|
|
54
|
+
};
|
|
43
55
|
const capabilities = createCapabilityRegistry();
|
|
44
56
|
capabilities.provide(appMethods, {
|
|
45
57
|
call: (method, inputJson) => {
|
|
58
|
+
if (method.startsWith("apps.")) {
|
|
59
|
+
appCalls.push(method);
|
|
60
|
+
const counter = document.getElementById("app-calls");
|
|
61
|
+
if (counter) counter.textContent = JSON.stringify(appCalls);
|
|
62
|
+
}
|
|
63
|
+
if (method === "apps.register") {
|
|
64
|
+
z.strictObject({ repo: z.literal(name) }).parse(JSON.parse(inputJson));
|
|
65
|
+
registrations += 1;
|
|
66
|
+
const scenario = new URLSearchParams(location.search).get("scenario");
|
|
67
|
+
if (scenario === "registration-refused")
|
|
68
|
+
return Promise.resolve({ error: "Owner only" });
|
|
69
|
+
if (scenario === "registration-invalid")
|
|
70
|
+
return Promise.resolve({
|
|
71
|
+
error: "Repository main must declare a valid fungi.build recipe",
|
|
72
|
+
});
|
|
73
|
+
if (scenario === "registration-unavailable" && registrations === 1)
|
|
74
|
+
return Promise.resolve({ error: "App registration is unavailable" });
|
|
75
|
+
return Promise.resolve({
|
|
76
|
+
appId: "app_settingstest",
|
|
77
|
+
sourceRevision: "git:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (method === "apps.startBuildWorkflow") {
|
|
81
|
+
z.strictObject({
|
|
82
|
+
repo: z.literal(name),
|
|
83
|
+
sourceRevision: z.literal("git:" + "b".repeat(40)),
|
|
84
|
+
installationId: z.literal("edited-installation"),
|
|
85
|
+
requestId: z.string().min(1).max(256),
|
|
86
|
+
}).parse(JSON.parse(inputJson));
|
|
87
|
+
return Promise.resolve({
|
|
88
|
+
kind: "started",
|
|
89
|
+
runId: "run_settings",
|
|
90
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (method === "apps.buildWorkflowRun") {
|
|
94
|
+
z.strictObject({ runId: z.literal("run_settings") }).parse(
|
|
95
|
+
JSON.parse(inputJson),
|
|
96
|
+
);
|
|
97
|
+
if (
|
|
98
|
+
new URLSearchParams(location.search).get("scenario") ===
|
|
99
|
+
"manual-workflow-refused"
|
|
100
|
+
)
|
|
101
|
+
return Promise.resolve({
|
|
102
|
+
runId: "run_settings",
|
|
103
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
104
|
+
outcome: "terminal",
|
|
105
|
+
title: "Build on merge",
|
|
106
|
+
status: "errored",
|
|
107
|
+
output: null,
|
|
108
|
+
error: {
|
|
109
|
+
name: "Error",
|
|
110
|
+
message:
|
|
111
|
+
"App method refused (unavailable): No hosted build backend is attached to this Computer",
|
|
112
|
+
},
|
|
113
|
+
reviews: [],
|
|
114
|
+
events: [
|
|
115
|
+
{
|
|
116
|
+
nodeId: "build",
|
|
117
|
+
iteration: "0",
|
|
118
|
+
poll: 0,
|
|
119
|
+
state: "failed",
|
|
120
|
+
attempt: 1,
|
|
121
|
+
refusal: "dispatch_failed",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
const release = {
|
|
126
|
+
appId: "app_settingstest",
|
|
127
|
+
releaseId: "rel_settings",
|
|
128
|
+
artifact: {
|
|
129
|
+
artifactRef: "artifact:sha256-" + "b".repeat(64),
|
|
130
|
+
artifactDigest: "sha256:" + "b".repeat(64),
|
|
131
|
+
provenance: {
|
|
132
|
+
sourceRevision: "git:" + "b".repeat(40),
|
|
133
|
+
sourceTreeDigest: "sha256:" + "c".repeat(64),
|
|
134
|
+
buildRecipeDigest: "sha256:" + "d".repeat(64),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
return Promise.resolve({
|
|
139
|
+
runId: "run_settings",
|
|
140
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
141
|
+
outcome: "terminal",
|
|
142
|
+
title: "Build on merge",
|
|
143
|
+
status: "complete",
|
|
144
|
+
output: {
|
|
145
|
+
installation: {
|
|
146
|
+
installationId: "edited-installation",
|
|
147
|
+
status: "enabled",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
error: null,
|
|
151
|
+
reviews: [],
|
|
152
|
+
events: [
|
|
153
|
+
{
|
|
154
|
+
nodeId: "build",
|
|
155
|
+
iteration: "0",
|
|
156
|
+
state: "complete",
|
|
157
|
+
attempt: 0,
|
|
158
|
+
output: {
|
|
159
|
+
...build,
|
|
160
|
+
sourceRevision: "git:" + "b".repeat(40),
|
|
161
|
+
installation: {
|
|
162
|
+
kind: "pending",
|
|
163
|
+
installationId: "edited-installation",
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
row: {
|
|
167
|
+
id: "build_settings",
|
|
168
|
+
title: "Build running",
|
|
169
|
+
detail: "git:" + "b".repeat(40),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
nodeId: "verify",
|
|
174
|
+
iteration: "0",
|
|
175
|
+
state: "complete",
|
|
176
|
+
attempt: 0,
|
|
177
|
+
output: {
|
|
178
|
+
release,
|
|
179
|
+
summary:
|
|
180
|
+
"Built rel_settings of app_settingstest from git:" +
|
|
181
|
+
"b".repeat(40),
|
|
182
|
+
},
|
|
183
|
+
row: {
|
|
184
|
+
id: "rel_settings",
|
|
185
|
+
title: "Release verified",
|
|
186
|
+
detail: "Exact source verified",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
nodeId: "install",
|
|
191
|
+
iteration: "0",
|
|
192
|
+
state: "complete",
|
|
193
|
+
attempt: 0,
|
|
194
|
+
output: {
|
|
195
|
+
installationId: "edited-installation",
|
|
196
|
+
status: "enabled",
|
|
197
|
+
},
|
|
198
|
+
row: {
|
|
199
|
+
id: "apps.install",
|
|
200
|
+
title: "Release installed",
|
|
201
|
+
detail: "edited-installation",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (method === "apps.buildStatus") {
|
|
208
|
+
const input = appBuildStatusInput.parse(JSON.parse(inputJson));
|
|
209
|
+
if (
|
|
210
|
+
new URLSearchParams(location.search).get("scenario") ===
|
|
211
|
+
"automatic-pending"
|
|
212
|
+
)
|
|
213
|
+
return Promise.resolve(pending(5000));
|
|
214
|
+
if (
|
|
215
|
+
"repo" in input &&
|
|
216
|
+
new URLSearchParams(location.search).get("scenario") !==
|
|
217
|
+
"automatic-needs-consent"
|
|
218
|
+
)
|
|
219
|
+
return Promise.resolve(null);
|
|
220
|
+
return Promise.resolve({
|
|
221
|
+
...build,
|
|
222
|
+
status: "succeeded",
|
|
223
|
+
installation: { kind: "needs_consent", installationId: name },
|
|
224
|
+
release: {
|
|
225
|
+
appId: build.appId,
|
|
226
|
+
releaseId: build.releaseId,
|
|
227
|
+
artifact: {
|
|
228
|
+
artifactRef: "artifact:sha256-" + "b".repeat(64),
|
|
229
|
+
artifactDigest: "sha256:" + "b".repeat(64),
|
|
230
|
+
provenance: {
|
|
231
|
+
sourceRevision: build.sourceRevision,
|
|
232
|
+
sourceTreeDigest: "sha256:" + "c".repeat(64),
|
|
233
|
+
buildRecipeDigest: "sha256:" + "d".repeat(64),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
46
239
|
if (method === "repos.delete") {
|
|
47
240
|
z.strictObject({ repo: z.literal(name) }).parse(JSON.parse(inputJson));
|
|
48
241
|
attempts += 1;
|
|
@@ -91,6 +284,9 @@ function Shell() {
|
|
|
91
284
|
<h1>Repositories</h1>
|
|
92
285
|
)}
|
|
93
286
|
</main>
|
|
287
|
+
<output id="app-calls" hidden>
|
|
288
|
+
{JSON.stringify([])}
|
|
289
|
+
</output>
|
|
94
290
|
<output id="attempts" hidden>
|
|
95
291
|
0
|
|
96
292
|
</output>
|
|
@@ -1,39 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
repositoryBrowserFixture,
|
|
3
|
+
repositoryBrowserPage,
|
|
4
|
+
startManualBuildWorkflow,
|
|
5
|
+
assertNoDirectInstall,
|
|
6
|
+
assertAuthoringFields,
|
|
7
|
+
} from "./repository-browser.fixture.mjs";
|
|
1
8
|
import assert from "node:assert/strict";
|
|
2
9
|
import console from "node:console";
|
|
3
10
|
import process from "node:process";
|
|
4
|
-
import { mkdir } from "node:fs/promises";
|
|
5
11
|
import { fileURLToPath, URL } from "node:url";
|
|
6
|
-
import { createServer } from "vite";
|
|
7
|
-
import { chromium } from "playwright";
|
|
8
|
-
import viteConfig from "./vite.config.mjs";
|
|
9
12
|
|
|
10
13
|
const output = new URL(
|
|
11
14
|
"../../../../.botanical/repos-delete/shots/",
|
|
12
15
|
import.meta.url,
|
|
13
16
|
);
|
|
14
|
-
await
|
|
15
|
-
const
|
|
16
|
-
...viteConfig,
|
|
17
|
-
configFile: false,
|
|
18
|
-
server: { ...viteConfig.server, port: 0 },
|
|
19
|
-
});
|
|
20
|
-
await server.listen();
|
|
21
|
-
const origin = server.resolvedUrls.local[0];
|
|
22
|
-
let browser;
|
|
17
|
+
const fixture = await repositoryBrowserFixture(output);
|
|
18
|
+
const { browser, origin } = fixture;
|
|
23
19
|
const name = "disposable-merge-1791840651624";
|
|
24
20
|
const before = process.argv.includes("--before");
|
|
21
|
+
const appWrites = async (page) =>
|
|
22
|
+
JSON.parse(await page.locator("#app-calls").textContent()).filter(
|
|
23
|
+
(method) =>
|
|
24
|
+
method !== "apps.buildStatus" && method !== "apps.buildWorkflowRun",
|
|
25
|
+
);
|
|
25
26
|
try {
|
|
26
|
-
browser = await chromium.launch({ headless: true });
|
|
27
27
|
for (const width of [1440, 390])
|
|
28
28
|
for (const theme of ["light", "dark"]) {
|
|
29
|
-
const page = await
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
const { page, errors } = await repositoryBrowserPage(
|
|
30
|
+
browser,
|
|
31
|
+
width,
|
|
32
|
+
theme,
|
|
33
|
+
);
|
|
34
|
+
if (!before) {
|
|
35
|
+
await page.goto(
|
|
36
|
+
origin +
|
|
37
|
+
"test/browser/repository-settings.html?scenario=automatic-needs-consent",
|
|
38
|
+
);
|
|
39
|
+
for (let visit = 0; visit < 2; visit++) {
|
|
40
|
+
if (visit > 0) await page.reload();
|
|
41
|
+
await page
|
|
42
|
+
.getByRole("link", { name: "Settings", exact: true })
|
|
43
|
+
.click();
|
|
44
|
+
await page
|
|
45
|
+
.getByRole("status")
|
|
46
|
+
.filter({ hasText: "Needs owner consent" })
|
|
47
|
+
.waitFor();
|
|
48
|
+
await assertAuthoringFields(page, {
|
|
49
|
+
appId: "app_settingstest",
|
|
50
|
+
sourceRevision: "git:" + "a".repeat(40),
|
|
51
|
+
installationId: name,
|
|
52
|
+
});
|
|
53
|
+
await assertNoDirectInstall(page);
|
|
54
|
+
assert.equal(
|
|
55
|
+
await page
|
|
56
|
+
.getByRole("button", { name: "Build & install", exact: true })
|
|
57
|
+
.isEnabled(),
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
await appWrites(page),
|
|
62
|
+
[],
|
|
63
|
+
"Status discovery and reload must not register, build or install",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
await page.screenshot({
|
|
67
|
+
path: fileURLToPath(
|
|
68
|
+
new URL(width + "-" + theme + "-automatic-reload.png", output),
|
|
69
|
+
),
|
|
70
|
+
fullPage: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (!before) {
|
|
74
|
+
await page.goto(
|
|
75
|
+
origin +
|
|
76
|
+
"test/browser/repository-settings.html?scenario=automatic-pending",
|
|
77
|
+
);
|
|
78
|
+
await page.getByRole("link", { name: "Settings", exact: true }).click();
|
|
79
|
+
await page
|
|
80
|
+
.getByRole("status")
|
|
81
|
+
.filter({
|
|
82
|
+
hasText: "Build or automatic installation is still running",
|
|
83
|
+
})
|
|
84
|
+
.waitFor();
|
|
85
|
+
assert.deepEqual(await appWrites(page), []);
|
|
86
|
+
}
|
|
87
|
+
await page.goto(
|
|
88
|
+
origin +
|
|
89
|
+
"test/browser/repository-settings.html?scenario=manual-workflow-refused",
|
|
90
|
+
);
|
|
37
91
|
await page.evaluate(
|
|
38
92
|
(value) =>
|
|
39
93
|
globalThis.document.documentElement.setAttribute("data-theme", value),
|
|
@@ -56,10 +110,65 @@ try {
|
|
|
56
110
|
await page
|
|
57
111
|
.getByRole("heading", { name: "Repository settings", exact: true })
|
|
58
112
|
.waitFor();
|
|
113
|
+
await page
|
|
114
|
+
.getByRole("button", { name: "Make this an App", exact: true })
|
|
115
|
+
.click();
|
|
116
|
+
await page
|
|
117
|
+
.locator('#app-build-app-id[value="app_settingstest"]')
|
|
118
|
+
.waitFor();
|
|
119
|
+
await assertAuthoringFields(page, {
|
|
120
|
+
appId: "app_settingstest",
|
|
121
|
+
sourceRevision: "git:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
122
|
+
installationId: name,
|
|
123
|
+
});
|
|
124
|
+
await page
|
|
125
|
+
.getByRole("button", { name: "Build & install", exact: true })
|
|
126
|
+
.and(page.locator(":enabled"))
|
|
127
|
+
.waitFor();
|
|
128
|
+
assert.equal(
|
|
129
|
+
await page
|
|
130
|
+
.getByRole("button", { name: "Build & install", exact: true })
|
|
131
|
+
.isEnabled(),
|
|
132
|
+
true,
|
|
133
|
+
);
|
|
134
|
+
await assertNoDirectInstall(page);
|
|
135
|
+
assert.deepEqual(await appWrites(page), ["apps.register"]);
|
|
59
136
|
assert.equal(await page.locator("#attempts").textContent(), "0");
|
|
60
137
|
await page.screenshot({
|
|
61
138
|
path: fileURLToPath(new URL(`${width}-${theme}-settings.png`, output)),
|
|
62
139
|
});
|
|
140
|
+
assert.equal(
|
|
141
|
+
await page
|
|
142
|
+
.getByRole("textbox", { name: "App id", exact: true })
|
|
143
|
+
.isEditable(),
|
|
144
|
+
false,
|
|
145
|
+
);
|
|
146
|
+
await startManualBuildWorkflow(
|
|
147
|
+
page,
|
|
148
|
+
"git:" + "b".repeat(40),
|
|
149
|
+
"edited-installation",
|
|
150
|
+
);
|
|
151
|
+
await page
|
|
152
|
+
.getByRole("alert")
|
|
153
|
+
.filter({
|
|
154
|
+
hasText: "No hosted build backend is attached to this Computer",
|
|
155
|
+
})
|
|
156
|
+
.waitFor();
|
|
157
|
+
await assertAuthoringFields(page, {
|
|
158
|
+
appId: "app_settingstest",
|
|
159
|
+
sourceRevision: "git:" + "b".repeat(40),
|
|
160
|
+
installationId: "edited-installation",
|
|
161
|
+
});
|
|
162
|
+
assert.deepEqual(await appWrites(page), [
|
|
163
|
+
"apps.register",
|
|
164
|
+
"apps.startBuildWorkflow",
|
|
165
|
+
]);
|
|
166
|
+
await assertNoDirectInstall(page);
|
|
167
|
+
await page.screenshot({
|
|
168
|
+
path: fileURLToPath(
|
|
169
|
+
new URL(`${width}-${theme}-build-refusal.png`, output),
|
|
170
|
+
),
|
|
171
|
+
});
|
|
63
172
|
await page
|
|
64
173
|
.getByRole("button", { name: "Delete repository", exact: true })
|
|
65
174
|
.click();
|
|
@@ -88,7 +197,16 @@ try {
|
|
|
88
197
|
hasText: "Deletion didn't finish because storage was unavailable.",
|
|
89
198
|
})
|
|
90
199
|
.waitFor();
|
|
91
|
-
assert.equal(
|
|
200
|
+
assert.equal(
|
|
201
|
+
await page
|
|
202
|
+
.getByRole("alert")
|
|
203
|
+
.filter({
|
|
204
|
+
hasText: "Deletion didn't finish because storage was unavailable.",
|
|
205
|
+
})
|
|
206
|
+
.locator("p")
|
|
207
|
+
.count(),
|
|
208
|
+
1,
|
|
209
|
+
);
|
|
92
210
|
assert.equal(await page.locator("#attempts").textContent(), "1");
|
|
93
211
|
await page.screenshot({
|
|
94
212
|
path: fileURLToPath(new URL(`${width}-${theme}-retry.png`, output)),
|
|
@@ -111,6 +229,51 @@ try {
|
|
|
111
229
|
.getByRole("alert")
|
|
112
230
|
.filter({ hasText: "Only the owner" })
|
|
113
231
|
.waitFor();
|
|
232
|
+
for (const [scenario, message] of [
|
|
233
|
+
["registration-refused", "Owner only"],
|
|
234
|
+
[
|
|
235
|
+
"registration-invalid",
|
|
236
|
+
"Repository main must declare a valid fungi.build recipe",
|
|
237
|
+
],
|
|
238
|
+
["registration-unavailable", "App registration is unavailable"],
|
|
239
|
+
]) {
|
|
240
|
+
await page.goto(
|
|
241
|
+
origin + "test/browser/repository-settings.html?scenario=" + scenario,
|
|
242
|
+
);
|
|
243
|
+
await page.getByRole("link", { name: "Settings", exact: true }).click();
|
|
244
|
+
await page
|
|
245
|
+
.getByRole("button", { name: "Make this an App", exact: true })
|
|
246
|
+
.click();
|
|
247
|
+
await page.getByRole("alert").filter({ hasText: message }).waitFor();
|
|
248
|
+
|
|
249
|
+
await assertAuthoringFields(page, {
|
|
250
|
+
appId: "",
|
|
251
|
+
sourceRevision: "",
|
|
252
|
+
installationId: name,
|
|
253
|
+
});
|
|
254
|
+
assert.equal(
|
|
255
|
+
await page
|
|
256
|
+
.getByRole("button", { name: "Build & install", exact: true })
|
|
257
|
+
.isDisabled(),
|
|
258
|
+
true,
|
|
259
|
+
);
|
|
260
|
+
await assertNoDirectInstall(page);
|
|
261
|
+
assert.deepEqual(await appWrites(page), ["apps.register"]);
|
|
262
|
+
await page.screenshot({
|
|
263
|
+
path: fileURLToPath(
|
|
264
|
+
new URL([width, theme, scenario].join("-") + ".png", output),
|
|
265
|
+
),
|
|
266
|
+
});
|
|
267
|
+
if (scenario === "registration-unavailable") {
|
|
268
|
+
await page
|
|
269
|
+
.getByRole("button", { name: "Make this an App", exact: true })
|
|
270
|
+
.click();
|
|
271
|
+
await page
|
|
272
|
+
.locator('#app-build-app-id[value="app_settingstest"]')
|
|
273
|
+
.waitFor();
|
|
274
|
+
assert.equal(await page.getByRole("alert").count(), 0);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
114
277
|
assert.deepEqual(errors, []);
|
|
115
278
|
assert.equal(
|
|
116
279
|
await page.evaluate(
|
|
@@ -126,11 +289,10 @@ try {
|
|
|
126
289
|
console.log(
|
|
127
290
|
before
|
|
128
291
|
? "PASS baseline 1440/390 light/dark: repository view before Settings"
|
|
129
|
-
: "PASS Settings 1440/390 light/dark:
|
|
292
|
+
: "PASS Settings 1440/390 light/dark: App registration/prefill, readonly identity, editable head/name and honest build refusal; typed needs-consent status without installing; denied/invalid/unavailable registration and retry; deletion confirmation/cancel/refusal/provider retry/navigation",
|
|
130
293
|
);
|
|
131
294
|
} finally {
|
|
132
|
-
await
|
|
133
|
-
await server.close();
|
|
295
|
+
await fixture.close();
|
|
134
296
|
console.log("CLOSED browser and Vite server");
|
|
135
297
|
}
|
|
136
298
|
process.exitCode = 0;
|
|
@@ -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
|
);
|