@fungi.computer/bonsai 0.0.0-stage → 0.0.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +835 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +5 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +715 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
// Consume a host-captured, unchanged Shed Release on the isolated native fixture.
|
|
2
|
+
// Usage: test:shed-terminal <shed-directory> <shed-revision> <tourtois-directory> <tourtois-revision>
|
|
3
|
+
// Run SECOND, only after the fixture custodian's handoff, under the bounded
|
|
4
|
+
// proof runner and HEAVY_CLASS=prepush with four-core affinity.
|
|
5
|
+
// CELLD_PLATFORM_CELLD + wrangler-on-celld.mjs selects celld; otherwise workerd.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
8
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { URL, fileURLToPath } from "node:url";
|
|
14
|
+
import { parseEnv } from "node:util";
|
|
15
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
16
|
+
import * as Schema from "effect/Schema";
|
|
17
|
+
import * as Redacted from "effect/Redacted";
|
|
18
|
+
import { chromium, expect } from "playwright/test";
|
|
19
|
+
import * as z from "zod";
|
|
20
|
+
import { installRelease } from "@fungi.computer/oyster/pack";
|
|
21
|
+
import { parseSourceRevision } from "@fungi.computer/puffball";
|
|
22
|
+
import { releaseSchema } from "@fungi.computer/puffball/effect";
|
|
23
|
+
import { workbenchDirectorySchema } from "@fungi.computer/oyster/shell/workbench-directory";
|
|
24
|
+
|
|
25
|
+
const [packedDirectory, expectedRevision, tourtoisDirectory, tourtoisRevision] =
|
|
26
|
+
z
|
|
27
|
+
.tuple([
|
|
28
|
+
z.string().min(1),
|
|
29
|
+
z.string().min(1),
|
|
30
|
+
z.string().min(1),
|
|
31
|
+
z.string().min(1),
|
|
32
|
+
])
|
|
33
|
+
.parse(process.argv.slice(2));
|
|
34
|
+
const directory = resolve(packedDirectory);
|
|
35
|
+
const release = Schema.decodeUnknownSync(Schema.fromJsonString(releaseSchema))(
|
|
36
|
+
await readFile(join(directory, "release.json"), "utf8"),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(release.appId, "app_shed");
|
|
39
|
+
assert.equal(
|
|
40
|
+
release.artifact.provenance.sourceRevision,
|
|
41
|
+
parseSourceRevision(expectedRevision),
|
|
42
|
+
"Install the exact immutable Shed capture released by its owner",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const tourtois = Schema.decodeUnknownSync(Schema.fromJsonString(releaseSchema))(
|
|
46
|
+
await readFile(join(resolve(tourtoisDirectory), "release.json"), "utf8"),
|
|
47
|
+
);
|
|
48
|
+
assert.equal(tourtois.appId, "app_tourtois");
|
|
49
|
+
assert.equal(
|
|
50
|
+
tourtois.artifact.provenance.sourceRevision,
|
|
51
|
+
parseSourceRevision(tourtoisRevision),
|
|
52
|
+
"Install the exact immutable Tourtois capture released by its owner",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const fixturePath = process.env.OYSTER_PROOF_ENV_FILE;
|
|
56
|
+
assert.ok(
|
|
57
|
+
fixturePath,
|
|
58
|
+
"OYSTER_PROOF_ENV_FILE must name the released private fixture",
|
|
59
|
+
);
|
|
60
|
+
assert.equal((await stat(fixturePath)).mode & 0o777, 0o600);
|
|
61
|
+
const fixture = z
|
|
62
|
+
.object({
|
|
63
|
+
COMPUTER_PROOF_PORT: z.coerce.number().int().min(1).max(65535),
|
|
64
|
+
COMPUTER_VM_ORIGIN: z.url(),
|
|
65
|
+
COMPUTER_VM_DEFAULT_HOST: z.string().min(1),
|
|
66
|
+
COMPUTER_VM_HOSTS: z.string().min(1),
|
|
67
|
+
COMPUTER_VM_SIGNING_KEY: z.string().min(1),
|
|
68
|
+
})
|
|
69
|
+
.safeParse(parseEnv(await readFile(fixturePath, "utf8")));
|
|
70
|
+
assert.ok(fixture.success, "Fixture configuration is invalid");
|
|
71
|
+
const { COMPUTER_PROOF_PORT: port, ...computerVars } = fixture.data;
|
|
72
|
+
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
73
|
+
const repository = fileURLToPath(new URL("../../../", import.meta.url));
|
|
74
|
+
const runtime = process.env.CELLD_PLATFORM_CELLD ? "celld" : "workerd";
|
|
75
|
+
await mkdir(`${root}.botanical`, { recursive: true });
|
|
76
|
+
const work = await mkdtemp(`${root}.botanical/shed-terminal-${runtime}-`);
|
|
77
|
+
const revision = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
78
|
+
cwd: repository,
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
}).trim();
|
|
81
|
+
const key = randomBytes(24).toString("hex");
|
|
82
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
83
|
+
// The standalone worker's own tenancy admits only an HTTPS origin with no
|
|
84
|
+
// path/credentials (packages/oyster/src/standalone/tenancy.ts's composeTenancy,
|
|
85
|
+
// packages/tenancy/src/registry-record.ts's checkedOrigin) — a bare
|
|
86
|
+
// http://127.0.0.1 origin throws "Tenant origin must be HTTPS...".
|
|
87
|
+
// --local-upstream/--upstream-protocol make wrangler's local dev server
|
|
88
|
+
// declare that exact origin to the Worker while still listening on plain
|
|
89
|
+
// loopback HTTP, the same sentinel convention every other native/browser
|
|
90
|
+
// proof of this standalone worker already uses (edit-in-inkcap-live,
|
|
91
|
+
// tourtois-home, landed-push, workflows, agents-sandbox).
|
|
92
|
+
//
|
|
93
|
+
// Opus-PT's reviewed fix for the Ports tray additionally calls for
|
|
94
|
+
// OYSTER_PREVIEW_ORIGIN (packages/oyster/src/standalone/preview.ts's
|
|
95
|
+
// previewOrigins; packages/oyster/src/standalone/computer.ts:625-626 returns
|
|
96
|
+
// port_previews_not_configured without it) and dropping --local-upstream
|
|
97
|
+
// for --local-protocol https, since --local-upstream rewrites every
|
|
98
|
+
// request's Host to the same single value and the preview popup can never
|
|
99
|
+
// reach a second, distinct tunnel origin through it. Tried exactly that
|
|
100
|
+
// (OYSTER_PREVIEW_ORIGIN=https://127.0.0.1:<port> alongside
|
|
101
|
+
// OYSTER_ORIGIN=https://localhost:<port>, --local-protocol https,
|
|
102
|
+
// NODE_TLS_REJECT_UNAUTHORIZED=0 for this script's own Node-side fetches,
|
|
103
|
+
// ignoreHTTPSErrors on the browser context) and it broke something more
|
|
104
|
+
// fundamental: the scratch vmd fixture's own --worker callback is a fixed
|
|
105
|
+
// plain-HTTP URL set at vmd's own launch (http://127.0.0.1:<this port>,
|
|
106
|
+
// from s5-vm-fixture.sh), and workerd's now-HTTPS-only local listener
|
|
107
|
+
// refuses that plaintext callback outright — confirmed directly in
|
|
108
|
+
// workerd's own log: "error accepting tls connection ... OpenSSL error;
|
|
109
|
+
// message = error:1000009c:SSL routines:OPENSSL_internal:HTTP_REQUEST".
|
|
110
|
+
// Every Computer-backed call then retried twice at 30s and failed (the
|
|
111
|
+
// same two-attempts-at-30s shape Sonnet-SG found for Workspace push).
|
|
112
|
+
// Fixing that needs vmd restarted with a matching https callback — not
|
|
113
|
+
// something to do unilaterally to a VM shared with Sonnet-SG's S9 work
|
|
114
|
+
// mid-session ("keep the VM up until S9 is green"). Reverted to the
|
|
115
|
+
// known-good --local-upstream transport for now; OYSTER_PREVIEW_ORIGIN
|
|
116
|
+
// stays set (harmless, satisfies half the fix) but the Ports tray itself
|
|
117
|
+
// is not expected to pass until this is resolved — see the READY/STATUS
|
|
118
|
+
// report for the exact finding and the open question.
|
|
119
|
+
const tenantHost = "oyster.test";
|
|
120
|
+
const previewHost = "oyster-preview.test";
|
|
121
|
+
const vars = {
|
|
122
|
+
...computerVars,
|
|
123
|
+
OYSTER_OWNER_KEY: key,
|
|
124
|
+
OYSTER_ORIGIN: `https://${tenantHost}`,
|
|
125
|
+
OYSTER_PREVIEW_ORIGIN: `https://${previewHost}`,
|
|
126
|
+
OYSTER_HOME_INSTALLATION: "tourtois",
|
|
127
|
+
};
|
|
128
|
+
const worker = spawn(
|
|
129
|
+
`${repository}node_modules/.bin/wrangler`,
|
|
130
|
+
[
|
|
131
|
+
"dev",
|
|
132
|
+
"--config",
|
|
133
|
+
`${root}wrangler.celld.jsonc`,
|
|
134
|
+
"--local",
|
|
135
|
+
"--ip",
|
|
136
|
+
"127.0.0.1",
|
|
137
|
+
"--port",
|
|
138
|
+
String(port),
|
|
139
|
+
"--persist-to",
|
|
140
|
+
`${work}/state`,
|
|
141
|
+
"--local-upstream",
|
|
142
|
+
tenantHost,
|
|
143
|
+
"--upstream-protocol",
|
|
144
|
+
"https",
|
|
145
|
+
...Object.entries(vars).flatMap(([name, value]) => [
|
|
146
|
+
"--var",
|
|
147
|
+
`${name}:${value}`,
|
|
148
|
+
]),
|
|
149
|
+
],
|
|
150
|
+
// Wrangler may report --var values; captured, not inherited, and only
|
|
151
|
+
// written out redacted (below) so secrets never reach this process's own
|
|
152
|
+
// stdout.
|
|
153
|
+
{ cwd: root, stdio: ["ignore", "pipe", "pipe"], env: process.env },
|
|
154
|
+
);
|
|
155
|
+
let workerOutput = "";
|
|
156
|
+
worker.stdout.on("data", (chunk) => {
|
|
157
|
+
workerOutput += chunk;
|
|
158
|
+
});
|
|
159
|
+
worker.stderr.on("data", (chunk) => {
|
|
160
|
+
workerOutput += chunk;
|
|
161
|
+
});
|
|
162
|
+
let startupFailed = false;
|
|
163
|
+
const stopped = new Promise((resolve) => {
|
|
164
|
+
worker.once("exit", resolve);
|
|
165
|
+
worker.once("error", () => {
|
|
166
|
+
startupFailed = true;
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
let browser;
|
|
171
|
+
const screenshots = [];
|
|
172
|
+
const viewports = [];
|
|
173
|
+
const redact = (text) =>
|
|
174
|
+
Object.values(vars).reduce(
|
|
175
|
+
(message, value) => message.replaceAll(value, "<redacted>"),
|
|
176
|
+
String(text),
|
|
177
|
+
);
|
|
178
|
+
try {
|
|
179
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
180
|
+
const ready = await globalThis
|
|
181
|
+
.fetch(`${origin}/login`, {
|
|
182
|
+
signal: globalThis.AbortSignal.timeout(1000),
|
|
183
|
+
})
|
|
184
|
+
.then(
|
|
185
|
+
(response) => response.ok,
|
|
186
|
+
() => false,
|
|
187
|
+
);
|
|
188
|
+
if (ready) break;
|
|
189
|
+
if (attempt >= 120 || worker.exitCode !== null || startupFailed)
|
|
190
|
+
throw new Error(
|
|
191
|
+
"Standalone Oyster did not start; terminal proof did not run",
|
|
192
|
+
);
|
|
193
|
+
await delay(500);
|
|
194
|
+
}
|
|
195
|
+
const home = await installRelease({
|
|
196
|
+
directory: resolve(tourtoisDirectory),
|
|
197
|
+
url: origin,
|
|
198
|
+
key,
|
|
199
|
+
installationId: "tourtois",
|
|
200
|
+
});
|
|
201
|
+
assert.equal(home.status, 201, "The unchanged Tourtois Release installs");
|
|
202
|
+
const installed = await installRelease({
|
|
203
|
+
directory,
|
|
204
|
+
url: origin,
|
|
205
|
+
key,
|
|
206
|
+
installationId: "shed",
|
|
207
|
+
});
|
|
208
|
+
assert.equal(installed.status, 201, "The unchanged Shed Release installs");
|
|
209
|
+
|
|
210
|
+
const command = await globalThis.fetch(
|
|
211
|
+
`${origin}/apps/shed/methods/runCommand`,
|
|
212
|
+
{
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: `Bearer ${key}`,
|
|
216
|
+
"Content-Type": "application/json",
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
command: "printf standalone-shed-terminal",
|
|
220
|
+
timeoutMs: 10000,
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
assert.equal(
|
|
225
|
+
command.status,
|
|
226
|
+
200,
|
|
227
|
+
"The unchanged Shed backend runs a native command",
|
|
228
|
+
);
|
|
229
|
+
const result = await command.json();
|
|
230
|
+
assert.equal(result.status, "succeeded");
|
|
231
|
+
assert.equal(result.output.status, "completed");
|
|
232
|
+
assert.equal(result.output.stdout, "standalone-shed-terminal");
|
|
233
|
+
assert.equal(result.output.stderr, "");
|
|
234
|
+
assert.equal(result.output.exitCode, 0);
|
|
235
|
+
|
|
236
|
+
const directoryResponse = await globalThis.fetch(
|
|
237
|
+
`${origin}/api/workbench/directory`,
|
|
238
|
+
{
|
|
239
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
assert.equal(directoryResponse.status, 200);
|
|
243
|
+
const { apps } = workbenchDirectorySchema.parse(
|
|
244
|
+
await directoryResponse.json(),
|
|
245
|
+
);
|
|
246
|
+
const installedShed = apps.filter((app) => app.installationId === "shed");
|
|
247
|
+
assert.equal(installedShed.length, 1);
|
|
248
|
+
const [shedApp] = installedShed;
|
|
249
|
+
assert.equal(shedApp.appId, release.appId);
|
|
250
|
+
assert.equal(shedApp.releaseId, release.releaseId);
|
|
251
|
+
assert.equal(shedApp.availability, "available");
|
|
252
|
+
assert.ok(shedApp.roles.includes("terminal"));
|
|
253
|
+
assert.equal(shedApp.displayName, "Shed");
|
|
254
|
+
|
|
255
|
+
browser = await chromium.launch();
|
|
256
|
+
for (const width of [1440, 390]) {
|
|
257
|
+
const page = await browser.newPage({
|
|
258
|
+
viewport: { width, height: width === 390 ? 844 : 900 },
|
|
259
|
+
reducedMotion: "reduce",
|
|
260
|
+
});
|
|
261
|
+
page.setDefaultTimeout(30_000);
|
|
262
|
+
const terminalCloseKey = `terminal-close-${randomBytes(8).toString("hex")}`;
|
|
263
|
+
// Navigation discards both Playwright and CDP socket-close notifications.
|
|
264
|
+
// Persist the shared owner's actual native close call across that navigation.
|
|
265
|
+
await page.addInitScript((key) => {
|
|
266
|
+
const close = globalThis.WebSocket.prototype.close;
|
|
267
|
+
globalThis.WebSocket.prototype.close = function (...args) {
|
|
268
|
+
const result = close.apply(this, args);
|
|
269
|
+
const url = new globalThis.URL(this.url);
|
|
270
|
+
if (
|
|
271
|
+
url.pathname === "/computer/terminal" &&
|
|
272
|
+
url.searchParams.has("session")
|
|
273
|
+
)
|
|
274
|
+
globalThis.sessionStorage.setItem(key, this.url);
|
|
275
|
+
return result;
|
|
276
|
+
};
|
|
277
|
+
}, terminalCloseKey);
|
|
278
|
+
const received = [];
|
|
279
|
+
const sent = [];
|
|
280
|
+
const diagnostics = {
|
|
281
|
+
errors: [],
|
|
282
|
+
sockets: [],
|
|
283
|
+
requests: [],
|
|
284
|
+
handshake: [],
|
|
285
|
+
};
|
|
286
|
+
page.on("pageerror", (error) =>
|
|
287
|
+
diagnostics.errors.push(redact(error.message)),
|
|
288
|
+
);
|
|
289
|
+
page.on("console", (message) => {
|
|
290
|
+
if (message.type() === "error")
|
|
291
|
+
diagnostics.errors.push(redact(message.text()));
|
|
292
|
+
});
|
|
293
|
+
page.on("requestfailed", (request) => {
|
|
294
|
+
diagnostics.requests.push({
|
|
295
|
+
path: new URL(request.url()).pathname,
|
|
296
|
+
error: redact(request.failure()?.errorText ?? "Request failed"),
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
await page.exposeFunction("recordAppHandshake", (type) => {
|
|
300
|
+
if (type === "fungi.app.ready" || type === "fungi.app.connect")
|
|
301
|
+
diagnostics.handshake.push(type);
|
|
302
|
+
});
|
|
303
|
+
await page.addInitScript(() => {
|
|
304
|
+
globalThis.addEventListener("message", (event) => {
|
|
305
|
+
if (
|
|
306
|
+
event.data?.type === "fungi.app.ready" ||
|
|
307
|
+
event.data?.type === "fungi.app.connect"
|
|
308
|
+
)
|
|
309
|
+
void globalThis.recordAppHandshake(event.data.type);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
let terminalSocket;
|
|
313
|
+
page.on("websocket", (socket) => {
|
|
314
|
+
const url = new URL(socket.url());
|
|
315
|
+
if (!url.searchParams.has("session") || !url.searchParams.has("cols"))
|
|
316
|
+
return;
|
|
317
|
+
terminalSocket = socket;
|
|
318
|
+
diagnostics.sockets.push({ path: url.pathname, event: "created" });
|
|
319
|
+
socket.on("framereceived", ({ payload }) => received.push(payload));
|
|
320
|
+
socket.on("framesent", ({ payload }) => sent.push(payload));
|
|
321
|
+
socket.on("socketerror", (error) =>
|
|
322
|
+
diagnostics.errors.push(redact(error)),
|
|
323
|
+
);
|
|
324
|
+
socket.on("close", () => {
|
|
325
|
+
diagnostics.sockets.push({ path: url.pathname, event: "closed" });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
try {
|
|
329
|
+
await page.goto(`${origin}/login`);
|
|
330
|
+
await page.getByLabel("Owner key").fill(key);
|
|
331
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
332
|
+
await page.goto(origin);
|
|
333
|
+
await expect(page).toHaveURL(`${origin}/apps/tourtois/`);
|
|
334
|
+
const desktop = page.locator("#oyster-shell [data-tourtois-shell]");
|
|
335
|
+
await expect(desktop).toBeVisible();
|
|
336
|
+
const before = `before-tourtois-${width}.png`;
|
|
337
|
+
await page.screenshot({ path: join(work, before), fullPage: true });
|
|
338
|
+
screenshots.push(before);
|
|
339
|
+
const [wasmResponse] = await Promise.all([
|
|
340
|
+
page.waitForResponse((response) => {
|
|
341
|
+
const url = new URL(response.url());
|
|
342
|
+
return (
|
|
343
|
+
url.origin === origin &&
|
|
344
|
+
url.pathname.startsWith("/ui/shed/") &&
|
|
345
|
+
url.pathname.endsWith(".wasm") &&
|
|
346
|
+
response.status() === 200
|
|
347
|
+
);
|
|
348
|
+
}),
|
|
349
|
+
desktop.getByRole("button", { name: "Shed", exact: true }).click(),
|
|
350
|
+
]);
|
|
351
|
+
assert.match(
|
|
352
|
+
wasmResponse.headers()["content-type"],
|
|
353
|
+
/^application\/wasm/,
|
|
354
|
+
);
|
|
355
|
+
const shed = page
|
|
356
|
+
.locator('.tourtois-app-pane[aria-label="Shed"]')
|
|
357
|
+
.frameLocator(".tourtois-pane-body iframe");
|
|
358
|
+
await expect(shed.locator("#shed-root")).toBeVisible();
|
|
359
|
+
await expect(
|
|
360
|
+
desktop.getByText("Terminal open", { exact: true }),
|
|
361
|
+
).toBeVisible({ timeout: 30_000 });
|
|
362
|
+
assert.ok(
|
|
363
|
+
terminalSocket,
|
|
364
|
+
"Shed opened the existing Computer PTY transport",
|
|
365
|
+
);
|
|
366
|
+
const ready = received
|
|
367
|
+
.filter((frame) => !Buffer.isBuffer(frame))
|
|
368
|
+
.map((frame) => JSON.parse(frame))
|
|
369
|
+
.find((frame) => frame.type === "ready");
|
|
370
|
+
assert.ok(
|
|
371
|
+
ready?.information?.computerName,
|
|
372
|
+
"The PTY supplies terminal metadata",
|
|
373
|
+
);
|
|
374
|
+
assert.equal(
|
|
375
|
+
ready.information.computerName,
|
|
376
|
+
"workspace",
|
|
377
|
+
"The VM Computer fixture's sole Computer is named workspace",
|
|
378
|
+
);
|
|
379
|
+
const nonce = randomBytes(8).toString("hex");
|
|
380
|
+
// Shed's light prompt is the default surface; `full` escalates one
|
|
381
|
+
// typed line to the real PTY. Once escalated, ShellPanel unmounts
|
|
382
|
+
// LightShellPanel — only a PTY exit (never triggered in this proof)
|
|
383
|
+
// remounts it — so every light-mode check below runs BEFORE the
|
|
384
|
+
// first `full`, and every later command is typed straight into the
|
|
385
|
+
// now-visible terminal instead.
|
|
386
|
+
const prompt = shed.getByRole("textbox", {
|
|
387
|
+
name: "Shell command",
|
|
388
|
+
exact: true,
|
|
389
|
+
});
|
|
390
|
+
const receivedBytes = () =>
|
|
391
|
+
Buffer.concat(received.filter((frame) => Buffer.isBuffer(frame)));
|
|
392
|
+
const sentBytesLength = () =>
|
|
393
|
+
sent.filter((frame) => Buffer.isBuffer(frame)).length;
|
|
394
|
+
|
|
395
|
+
// Row 50: a light-routed command (no `full`) runs through Shed's own
|
|
396
|
+
// runCommand App method, not the PTY. "Light mode" never requires the
|
|
397
|
+
// PTY socket to be absent (ShellPanel keeps it mounted for the
|
|
398
|
+
// banner/session facts); what must hold is that a light command's own
|
|
399
|
+
// bytes never reach the PTY input — checked here, before any
|
|
400
|
+
// escalation, while the light prompt is still the mounted surface.
|
|
401
|
+
const runCommandCalls = [];
|
|
402
|
+
page.on("request", (request) => {
|
|
403
|
+
if (request.url() === `${origin}/apps/shed/methods/runCommand`)
|
|
404
|
+
runCommandCalls.push(request.postDataJSON());
|
|
405
|
+
});
|
|
406
|
+
const beforeLightInput = sentBytesLength();
|
|
407
|
+
await prompt.click();
|
|
408
|
+
await prompt.fill("pwd");
|
|
409
|
+
await prompt.press("Enter");
|
|
410
|
+
await expect
|
|
411
|
+
.poll(() => runCommandCalls.some((call) => call.command === "pwd"), {
|
|
412
|
+
message: "A light command calls Shed's runCommand method",
|
|
413
|
+
timeout: 30_000,
|
|
414
|
+
})
|
|
415
|
+
.toBe(true);
|
|
416
|
+
assert.equal(
|
|
417
|
+
sentBytesLength(),
|
|
418
|
+
beforeLightInput,
|
|
419
|
+
"A light-routed command never writes bytes to the PTY input",
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const marker = `oyster-${nonce}-command`;
|
|
423
|
+
await prompt.click();
|
|
424
|
+
await prompt.fill(`full printf 'oyster-%s-command\\n' '${nonce}'`);
|
|
425
|
+
await prompt.press("Enter");
|
|
426
|
+
await expect
|
|
427
|
+
.poll(() => receivedBytes().includes(marker), {
|
|
428
|
+
message: "A real shell command returns through Shed's terminal",
|
|
429
|
+
timeout: 30_000,
|
|
430
|
+
})
|
|
431
|
+
.toBe(true);
|
|
432
|
+
const after = `after-shed-terminal-${width}.png`;
|
|
433
|
+
await page.screenshot({ path: join(work, after), fullPage: true });
|
|
434
|
+
screenshots.push(after);
|
|
435
|
+
|
|
436
|
+
// Row 49: pwd/ls/git log -1 --oneline, typed straight into the now
|
|
437
|
+
// visible real terminal (we are already escalated). Asserted on the
|
|
438
|
+
// raw PTY byte stream this proof already observes over the
|
|
439
|
+
// websocket — not a rendered grid: Ghostty's own cell buffer isn't
|
|
440
|
+
// introspectable from this packed Release outside a Vite dev server
|
|
441
|
+
// (unlike terminal-resize.browser.mjs's recording-ghostty trick).
|
|
442
|
+
// ghostty-web's open() (dist/ghostty-web.js) gives its mount element
|
|
443
|
+
// itself role="textbox" aria-label="Terminal input" (contenteditable,
|
|
444
|
+
// for the real visible terminal region) AND appends a second, 1px
|
|
445
|
+
// clipped <textarea> with the SAME role and label as its internal
|
|
446
|
+
// key-capture target — so that accessible name is not unique and
|
|
447
|
+
// getByRole(...) for it resolves ambiguously. Ghostty's own supported
|
|
448
|
+
// input path is: click its <canvas> (the visible rendered region,
|
|
449
|
+
// already styled by shed-pane.css's ".terminal-host canvas" rule),
|
|
450
|
+
// which focuses the hidden textarea internally; type and press keys
|
|
451
|
+
// against the page, never against the vendor's hidden input directly,
|
|
452
|
+
// and never touch shed's product DOM to disambiguate it.
|
|
453
|
+
const terminalRegion = shed.locator(".terminal-host canvas");
|
|
454
|
+
/**
|
|
455
|
+
* Type one line into the real terminal and wait for ITS OWN
|
|
456
|
+
* split-marker output — proof the command actually completed, never
|
|
457
|
+
* satisfied by the terminal's own echo of the typed line. The
|
|
458
|
+
* negative control is explicit: once the terminal has echoed enough
|
|
459
|
+
* bytes to show typing landed, the marker must still be absent,
|
|
460
|
+
* before Enter is even pressed (a bare terminal echoes keystrokes
|
|
461
|
+
* before a shell can act on them). No fixed delay anywhere in this
|
|
462
|
+
* helper.
|
|
463
|
+
*
|
|
464
|
+
* CONFIRMED against a real bounded terminal: a long typed line (the
|
|
465
|
+
* SETUP command below, chained and well over the row width) does NOT
|
|
466
|
+
* echo back as one flat contiguous copy of `line` — bash's own
|
|
467
|
+
* readline redraws past the viewport width with its own cursor
|
|
468
|
+
* movement instead, so `sinceStart().includes(line)` can time out
|
|
469
|
+
* forever even though the terminal is echoing correctly (observed
|
|
470
|
+
* directly in a failed run's screenshot: a horizontally-scrolled tail
|
|
471
|
+
* of the command, not line-wrapped, not the literal substring). The
|
|
472
|
+
* echo check below only waits for AT LEAST as many new bytes as were
|
|
473
|
+
* typed — proof that real echo happened — never for the exact text,
|
|
474
|
+
* so it holds regardless of wrap/redraw width. The actual "this
|
|
475
|
+
* command, not its echo" proof stays on the marker alone, which is
|
|
476
|
+
* short enough to always render intact.
|
|
477
|
+
*/
|
|
478
|
+
async function runFull(command, markerPrefix) {
|
|
479
|
+
const start = receivedBytes().length;
|
|
480
|
+
const sinceStart = () =>
|
|
481
|
+
receivedBytes().subarray(start).toString("utf8");
|
|
482
|
+
const markerNonce = randomBytes(8).toString("hex");
|
|
483
|
+
const lineMarker = `${markerPrefix}-${markerNonce}`;
|
|
484
|
+
const line = `${command}; printf '%s%s\\n' '${markerPrefix}-' '${markerNonce}'`;
|
|
485
|
+
assert.ok(
|
|
486
|
+
!line.includes(lineMarker),
|
|
487
|
+
"the typed line itself never contains the concatenated marker",
|
|
488
|
+
);
|
|
489
|
+
await terminalRegion.click();
|
|
490
|
+
await page.keyboard.type(line);
|
|
491
|
+
await expect
|
|
492
|
+
.poll(() => sinceStart().length >= line.length, {
|
|
493
|
+
message: `The terminal echoes the typed line for: ${command}`,
|
|
494
|
+
timeout: 10_000,
|
|
495
|
+
})
|
|
496
|
+
.toBe(true);
|
|
497
|
+
assert.ok(
|
|
498
|
+
!sinceStart().includes(lineMarker),
|
|
499
|
+
"echoing the typed line alone never satisfies the split marker " +
|
|
500
|
+
"(the negative control pr1-launch-smoke-apps.test.mjs also proves)",
|
|
501
|
+
);
|
|
502
|
+
await page.keyboard.press("Enter");
|
|
503
|
+
await expect
|
|
504
|
+
.poll(() => sinceStart().includes(lineMarker), {
|
|
505
|
+
message: `The PTY completes: ${command}`,
|
|
506
|
+
timeout: 30_000,
|
|
507
|
+
})
|
|
508
|
+
.toBe(true);
|
|
509
|
+
return sinceStart();
|
|
510
|
+
}
|
|
511
|
+
const probeDir = `/workspace/e2e-shed-proof-${nonce}`;
|
|
512
|
+
const knownFile = "known.txt";
|
|
513
|
+
// The VM's own git auto-detects an invalid email from its hostname
|
|
514
|
+
// ("fungi@computer.(none)") and refuses to commit without one
|
|
515
|
+
// explicitly configured — the same thing a real first-time user
|
|
516
|
+
// would hit, worked around here like any CI script would, with a
|
|
517
|
+
// disposable local identity (git config without --global, scoped to
|
|
518
|
+
// this probe repo alone).
|
|
519
|
+
await runFull(
|
|
520
|
+
`mkdir -p ${probeDir} && cd ${probeDir} && printf probe > ${knownFile} && git init -q && git config user.email proof@example.com && git config user.name "E2E proof" && git commit -q --allow-empty -m e2e-proof-commit`,
|
|
521
|
+
"SETUP",
|
|
522
|
+
);
|
|
523
|
+
// pwd's own output comes only from $(pwd) command substitution,
|
|
524
|
+
// evaluated at run time — the typed/echoed line never contains the
|
|
525
|
+
// real path, so any occurrence in this command's own output slice
|
|
526
|
+
// can only be the shell's real answer.
|
|
527
|
+
const pwdOutput = await runFull(`printf '%s' "$(pwd)"`, "PWD");
|
|
528
|
+
assert.ok(
|
|
529
|
+
pwdOutput.includes(probeDir),
|
|
530
|
+
"pwd's command-substitution output renders the real working directory",
|
|
531
|
+
);
|
|
532
|
+
const lsOutput = await runFull("ls", "LS");
|
|
533
|
+
assert.ok(
|
|
534
|
+
lsOutput.includes(knownFile),
|
|
535
|
+
"ls's own output lists the known seeded file",
|
|
536
|
+
);
|
|
537
|
+
// --color=never: git auto-colorizes --oneline with ANSI SGR codes
|
|
538
|
+
// when it detects a real PTY (confirmed: the hash and ref decoration
|
|
539
|
+
// render in distinct colors against the live terminal), which breaks
|
|
540
|
+
// a plain hash/subject regex on the raw byte stream.
|
|
541
|
+
const logOutput = await runFull(
|
|
542
|
+
"git log -1 --oneline --color=never",
|
|
543
|
+
"LOG",
|
|
544
|
+
);
|
|
545
|
+
assert.match(
|
|
546
|
+
logOutput,
|
|
547
|
+
// `--oneline` decorates the first commit of a fresh repo with its
|
|
548
|
+
// ref ("(HEAD -> master)") between the hash and the subject — real
|
|
549
|
+
// git behavior, not optional here.
|
|
550
|
+
/[0-9a-f]{7,}(?: \([^)]+\))? e2e-proof-commit/,
|
|
551
|
+
"git log -1 --oneline renders a real commit hash and subject",
|
|
552
|
+
);
|
|
553
|
+
assert.ok(
|
|
554
|
+
!logOutput.includes("�"),
|
|
555
|
+
"no U+FFFD replacement character in the raw PTY byte stream",
|
|
556
|
+
);
|
|
557
|
+
const ptyShot = `terminal-pty-bytes-${width}.png`;
|
|
558
|
+
await page.screenshot({ path: join(work, ptyShot), fullPage: true });
|
|
559
|
+
screenshots.push(ptyShot);
|
|
560
|
+
|
|
561
|
+
// Row 51: start a background server, expose its port, confirm the
|
|
562
|
+
// preview affordance renders, then stop the process. The popup's own
|
|
563
|
+
// content (fence asserts preview-popup.browser.mjs:340-456 already
|
|
564
|
+
// cover: no opener, not an embedded iframe, shows the real served
|
|
565
|
+
// page) is proven at the live demo, not here — see the comment
|
|
566
|
+
// below the "Open preview" visibility check for why this fixture
|
|
567
|
+
// itself cannot reach it. The Processes and Ports trays are
|
|
568
|
+
// Tourtois window chrome, not Shed's own light/full prompt, so they
|
|
569
|
+
// work the same escalated or not.
|
|
570
|
+
// Port 8099, not 8080: 8080 is computerDaemonPort, computerd's own
|
|
571
|
+
// reserved control-plane port (packages/computer/src/
|
|
572
|
+
// operation-contract.ts); starting a demo process there throws
|
|
573
|
+
// ComputerPortReservedError, confirmed against the real VM.
|
|
574
|
+
//
|
|
575
|
+
// Not python3 -m http.server: packages/computer/Dockerfile only
|
|
576
|
+
// installs python3 in its discarded intermediate "terminal" build
|
|
577
|
+
// stage (to compile node-pty's native addon); the shipped final image
|
|
578
|
+
// installs fuse3/libfuse2t64/ca-certificates/curl/git/tini and NOT
|
|
579
|
+
// python3 — confirmed directly against the real VM (`find /` finds no
|
|
580
|
+
// python3 anywhere, exit 127 "command not found"). node IS guaranteed
|
|
581
|
+
// present (it's the image's own base), so a one-line node http server
|
|
582
|
+
// replaces it, serving the same "Directory listing for /" heading
|
|
583
|
+
// python's stdlib server would (this fixture no longer opens the
|
|
584
|
+
// popup to check it, see below, but a real served page is still
|
|
585
|
+
// what's actually running behind the exposed port).
|
|
586
|
+
// The tray button's own accessible name now includes a running-count
|
|
587
|
+
// badge (e.g. "Processes 1"), so this matches the leading word only.
|
|
588
|
+
await desktop.getByRole("button", { name: /^Processes/ }).click();
|
|
589
|
+
const startForm = shed.getByRole("form", { name: "Start a process" });
|
|
590
|
+
await expect(startForm).toBeVisible();
|
|
591
|
+
const processCommand =
|
|
592
|
+
'node -e \'require("http").createServer((req,res)=>{res.writeHead(200,{"Content-Type":"text/html"});res.end("<h1>Directory listing for /</h1>")}).listen(8099)\'';
|
|
593
|
+
await startForm
|
|
594
|
+
.getByRole("textbox", { name: "Command" })
|
|
595
|
+
.fill(processCommand);
|
|
596
|
+
await startForm
|
|
597
|
+
.getByRole("spinbutton", { name: "Port (optional)" })
|
|
598
|
+
.fill("8099");
|
|
599
|
+
await startForm
|
|
600
|
+
.getByRole("button", { name: "Start", exact: true })
|
|
601
|
+
.click();
|
|
602
|
+
const processRow = shed.locator('[data-process-state="running"]');
|
|
603
|
+
await expect(processRow).toContainText(processCommand, {
|
|
604
|
+
timeout: 30_000,
|
|
605
|
+
});
|
|
606
|
+
// Same count-badge reason as Processes above ("Ports 1").
|
|
607
|
+
await desktop.getByRole("button", { name: /^Ports/ }).click();
|
|
608
|
+
const exposeForm = shed.getByRole("form", { name: "Expose a port" });
|
|
609
|
+
await expect(exposeForm).toBeVisible();
|
|
610
|
+
await exposeForm
|
|
611
|
+
.getByRole("spinbutton", { name: "Port", exact: true })
|
|
612
|
+
.fill("8099");
|
|
613
|
+
await exposeForm
|
|
614
|
+
.getByRole("button", { name: "Expose", exact: true })
|
|
615
|
+
.click();
|
|
616
|
+
const openPreview = shed.getByRole("button", {
|
|
617
|
+
name: "Open preview of port 8099",
|
|
618
|
+
exact: true,
|
|
619
|
+
});
|
|
620
|
+
await expect(openPreview).toBeVisible({ timeout: 30_000 });
|
|
621
|
+
// Stop here, by CTO decision: this VM fixture's wrangler runs under
|
|
622
|
+
// --local-upstream, which rewrites every request's Host to one fixed
|
|
623
|
+
// value, so the popup itself can never reach a second, distinct
|
|
624
|
+
// preview-tunnel origin through it (confirmed directly: the popup
|
|
625
|
+
// opens but its own "Directory listing" heading never arrives,
|
|
626
|
+
// because the popup's request never reaches the real backend). The
|
|
627
|
+
// real fix (a distinct HTTPS preview origin) needs --local-protocol
|
|
628
|
+
// https, which this VM fixture's vmd cannot yet speak back to
|
|
629
|
+
// (tooling/celld-platform/computer-vm/vmd.mjs's proxyHandlers leaks
|
|
630
|
+
// TLS socket listeners on its https branch — a separate, logged
|
|
631
|
+
// bug). The live demo runs on Cloudflare containers with a real
|
|
632
|
+
// certificate and a real preview origin, not this fixture, so its
|
|
633
|
+
// own rehearsal is the popup's real end-to-end proof; this proof
|
|
634
|
+
// asserts everything the fixture CAN reach for real: the process
|
|
635
|
+
// starts, Ports lists it, expose succeeds, and the preview
|
|
636
|
+
// affordance renders with the right port — then moves straight to
|
|
637
|
+
// Stop.
|
|
638
|
+
await desktop.getByRole("button", { name: /^Processes/ }).click();
|
|
639
|
+
// A HoldButton: stopping a process is a held, not a single, click.
|
|
640
|
+
await processRow
|
|
641
|
+
.getByRole("button", {
|
|
642
|
+
name: `Stop process ${processCommand}`,
|
|
643
|
+
exact: true,
|
|
644
|
+
})
|
|
645
|
+
.click({ delay: 1000 });
|
|
646
|
+
await expect(processRow).toHaveCount(0, { timeout: 30_000 });
|
|
647
|
+
|
|
648
|
+
const beforeResize = sent.length;
|
|
649
|
+
await page.setViewportSize({ width: width - 20, height: 800 });
|
|
650
|
+
await expect
|
|
651
|
+
.poll(
|
|
652
|
+
() =>
|
|
653
|
+
sent
|
|
654
|
+
.slice(beforeResize)
|
|
655
|
+
.filter((frame) => !Buffer.isBuffer(frame))
|
|
656
|
+
.map((frame) => JSON.parse(frame))
|
|
657
|
+
.some((frame) => frame.type === "resize"),
|
|
658
|
+
{
|
|
659
|
+
message: "Shed resizes the PTY using the existing resize frame",
|
|
660
|
+
timeout: 10_000,
|
|
661
|
+
},
|
|
662
|
+
)
|
|
663
|
+
.toBe(true);
|
|
664
|
+
// A reconnect's earlier close cannot satisfy navigation disposal.
|
|
665
|
+
await page.evaluate(
|
|
666
|
+
(key) => globalThis.sessionStorage.removeItem(key),
|
|
667
|
+
terminalCloseKey,
|
|
668
|
+
);
|
|
669
|
+
const terminalUrl = terminalSocket.url();
|
|
670
|
+
await page.goto(`${origin}/login`);
|
|
671
|
+
await expect
|
|
672
|
+
.poll(
|
|
673
|
+
() =>
|
|
674
|
+
page.evaluate(
|
|
675
|
+
(key) => globalThis.sessionStorage.getItem(key),
|
|
676
|
+
terminalCloseKey,
|
|
677
|
+
),
|
|
678
|
+
{
|
|
679
|
+
message: "Leaving Tourtois closes the Shed terminal transport",
|
|
680
|
+
timeout: 10_000,
|
|
681
|
+
},
|
|
682
|
+
)
|
|
683
|
+
.toBe(terminalUrl);
|
|
684
|
+
diagnostics.sockets.push({
|
|
685
|
+
path: new URL(terminalUrl).pathname,
|
|
686
|
+
event: "disposed-on-navigation",
|
|
687
|
+
});
|
|
688
|
+
viewports.push(width);
|
|
689
|
+
} catch (cause) {
|
|
690
|
+
await page.screenshot({
|
|
691
|
+
path: join(work, `failure-${width}.png`),
|
|
692
|
+
fullPage: true,
|
|
693
|
+
});
|
|
694
|
+
diagnostics.failure = redact(
|
|
695
|
+
cause instanceof Error ? cause.message : "Proof failed",
|
|
696
|
+
);
|
|
697
|
+
diagnostics.parentError = await page
|
|
698
|
+
.locator("body")
|
|
699
|
+
.getAttribute("data-oyster-error");
|
|
700
|
+
diagnostics.frames = await Promise.all(
|
|
701
|
+
page.frames().map(async (frame) => ({
|
|
702
|
+
path: new URL(frame.url(), origin).pathname,
|
|
703
|
+
text: redact(
|
|
704
|
+
await frame
|
|
705
|
+
.locator("body")
|
|
706
|
+
.innerText()
|
|
707
|
+
.catch((cause) =>
|
|
708
|
+
cause instanceof Error
|
|
709
|
+
? `Frame unavailable: ${cause.message}`
|
|
710
|
+
: "Frame unavailable: non-Error failure",
|
|
711
|
+
),
|
|
712
|
+
),
|
|
713
|
+
})),
|
|
714
|
+
);
|
|
715
|
+
const failureOptions = { cause: Redacted.make(cause) };
|
|
716
|
+
throw new Error(
|
|
717
|
+
`Shed ${runtime} proof failed at ${width}; private diagnostics: ${work}/diagnostics-${width}.json`,
|
|
718
|
+
failureOptions,
|
|
719
|
+
);
|
|
720
|
+
} finally {
|
|
721
|
+
await writeFile(
|
|
722
|
+
join(work, `diagnostics-${width}.json`),
|
|
723
|
+
JSON.stringify(diagnostics, null, 2),
|
|
724
|
+
{ mode: 0o600 },
|
|
725
|
+
);
|
|
726
|
+
await page.close();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
} finally {
|
|
730
|
+
try {
|
|
731
|
+
await browser?.close();
|
|
732
|
+
} finally {
|
|
733
|
+
if (
|
|
734
|
+
!startupFailed &&
|
|
735
|
+
worker.exitCode === null &&
|
|
736
|
+
worker.signalCode === null
|
|
737
|
+
)
|
|
738
|
+
worker.kill("SIGTERM");
|
|
739
|
+
await stopped;
|
|
740
|
+
await writeFile(join(work, "worker.log"), redact(workerOutput), {
|
|
741
|
+
mode: 0o600,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
await writeFile(
|
|
746
|
+
join(work, "receipt.json"),
|
|
747
|
+
JSON.stringify(
|
|
748
|
+
{
|
|
749
|
+
passed: true,
|
|
750
|
+
runtime,
|
|
751
|
+
rootRevision: revision,
|
|
752
|
+
sourceRevision: release.artifact.provenance.sourceRevision,
|
|
753
|
+
tourtoisRevision: tourtois.artifact.provenance.sourceRevision,
|
|
754
|
+
viewports,
|
|
755
|
+
releaseId: release.releaseId,
|
|
756
|
+
checks: [
|
|
757
|
+
"backend-command",
|
|
758
|
+
"terminal-metadata",
|
|
759
|
+
"light-command-runCommand-not-pty",
|
|
760
|
+
"full-pty-pwd-ls-gitlog-raw-bytes",
|
|
761
|
+
"terminal-command",
|
|
762
|
+
"process-start-expose-preview-stop",
|
|
763
|
+
"resize",
|
|
764
|
+
"close",
|
|
765
|
+
],
|
|
766
|
+
screenshots,
|
|
767
|
+
},
|
|
768
|
+
null,
|
|
769
|
+
2,
|
|
770
|
+
),
|
|
771
|
+
);
|
|
772
|
+
globalThis.console.log(
|
|
773
|
+
`PASS unchanged Shed on ${runtime}: backend command, metadata, light runCommand vs full PTY, pwd/ls/git log against the raw PTY byte stream (not a rendered grid), process start/expose/preview/stop, resize, close; ${work}/receipt.json`,
|
|
774
|
+
);
|