pinokiod 7.3.14 → 7.4.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.
Files changed (51) hide show
  1. package/kernel/api/index.js +111 -15
  2. package/kernel/api/script/index.js +10 -0
  3. package/kernel/autolaunch.js +111 -0
  4. package/kernel/environment.js +89 -1
  5. package/kernel/git.js +9 -19
  6. package/kernel/index.js +142 -43
  7. package/kernel/launch_requirements.js +1115 -0
  8. package/kernel/ready.js +231 -0
  9. package/kernel/script.js +16 -0
  10. package/kernel/shells.js +9 -1
  11. package/kernel/workspace_status.js +111 -45
  12. package/package.json +1 -1
  13. package/server/autolaunch.js +725 -0
  14. package/server/index.js +244 -411
  15. package/server/public/logs.js +15 -1
  16. package/server/public/style.css +99 -31
  17. package/server/views/app.ejs +263 -159
  18. package/server/views/autolaunch.ejs +363 -75
  19. package/server/views/index.ejs +550 -26
  20. package/server/views/logs.ejs +14 -12
  21. package/server/views/partials/app_autolaunch_dependency_events.ejs +99 -0
  22. package/server/views/partials/app_autolaunch_dependency_save.ejs +10 -0
  23. package/server/views/partials/app_autolaunch_dependency_styles.ejs +357 -0
  24. package/server/views/partials/app_autolaunch_modal_helpers.ejs +347 -0
  25. package/server/views/partials/autolaunch_dependency_helpers.ejs +204 -0
  26. package/server/views/partials/autolaunch_dependency_save.ejs +13 -0
  27. package/server/views/partials/autolaunch_dependency_styles.ejs +347 -0
  28. package/server/views/partials/home_action_modal.ejs +4 -1
  29. package/server/views/partials/launch_requirements_status_client.ejs +271 -0
  30. package/server/views/partials/launch_requirements_status_styles.ejs +171 -0
  31. package/server/views/partials/launch_settings_dependency_save_factory.ejs +45 -0
  32. package/server/views/partials/launch_settings_dependency_script_loader_factory.ejs +25 -0
  33. package/server/views/terminal.ejs +196 -2
  34. package/test/home-autolaunch-live-ui.test.js +455 -0
  35. package/test/launch-requirements-browser.test.js +579 -0
  36. package/test/launch-requirements-contract-coverage.test.js +627 -0
  37. package/test/launch-requirements-real-browser.js +625 -0
  38. package/test/launch-requirements-status-client.test.js +132 -0
  39. package/test/launch-requirements.test.js +1806 -0
  40. package/test/launch-settings-ui.test.js +370 -0
  41. package/test/ready-state.test.js +49 -0
  42. package/test/server-autolaunch.test.js +1052 -0
  43. package/test/startup-git-index-benchmark.js +409 -0
  44. package/test/startup-git-index-browser.js +320 -0
  45. package/test/startup-git-index-performance.test.js +380 -0
  46. package/test/startup-git-index-refactor.test.js +450 -0
  47. package/test/startup-git-index-route.test.js +588 -0
  48. package/test/universal-launcher.smoke.spec.js +10 -9
  49. package/test/workspace-gitignore-benchmark.js +815 -0
  50. package/test/workspace-gitignore-path-scoped.test.js +256 -0
  51. package/spec/INSTRUCTION_SYNC.md +0 -432
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env node
2
+ const assert = require("node:assert/strict")
3
+ const childProcess = require("node:child_process")
4
+ const fs = require("node:fs")
5
+ const fsp = require("node:fs/promises")
6
+ const http = require("node:http")
7
+ const net = require("node:net")
8
+ const os = require("node:os")
9
+ const path = require("node:path")
10
+
11
+ const repoRoot = path.resolve(__dirname, "..")
12
+ const outputRoot = path.resolve(repoRoot, "output/playwright/launch-requirements-real-browser")
13
+ const readyPrefix = "PINOKIO_REAL_BROWSER_READY "
14
+ const resultPrefix = "PINOKIO_REAL_BROWSER_RESULT "
15
+ const serverMode = process.argv.includes("--server")
16
+
17
+ function parseArgs(argv = process.argv.slice(2)) {
18
+ const args = {}
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const item = argv[i]
21
+ if (!item.startsWith("--")) {
22
+ continue
23
+ }
24
+ const key = item.slice(2)
25
+ const next = argv[i + 1]
26
+ if (next && !next.startsWith("--")) {
27
+ args[key] = next
28
+ i += 1
29
+ } else {
30
+ args[key] = true
31
+ }
32
+ }
33
+ return args
34
+ }
35
+
36
+ function loadPlaywright() {
37
+ try {
38
+ return require("playwright")
39
+ } catch (_) {}
40
+
41
+ try {
42
+ const bin = childProcess.execFileSync("which", ["playwright"], { encoding: "utf8" }).trim()
43
+ if (bin) {
44
+ return require(path.join(path.dirname(path.dirname(bin)), "playwright"))
45
+ }
46
+ } catch (_) {}
47
+
48
+ throw new Error(
49
+ "Playwright is required for the real-browser harness. Run: npx -y -p playwright node test/launch-requirements-real-browser.js"
50
+ )
51
+ }
52
+
53
+ function chromiumExecutablePath(playwright) {
54
+ const candidates = [
55
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
56
+ playwright.chromium.executablePath(),
57
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
58
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
59
+ ].filter(Boolean)
60
+ return candidates.find((candidate) => fs.existsSync(candidate)) || ""
61
+ }
62
+
63
+ async function freePort() {
64
+ return await new Promise((resolve, reject) => {
65
+ const server = net.createServer()
66
+ server.listen(0, "127.0.0.1", () => {
67
+ const port = server.address().port
68
+ server.close(() => resolve(port))
69
+ })
70
+ server.on("error", reject)
71
+ })
72
+ }
73
+
74
+ function jsString(value) {
75
+ return JSON.stringify(String(value))
76
+ }
77
+
78
+ function appModule(title, description) {
79
+ return `module.exports = {
80
+ version: "1.0.0",
81
+ title: ${jsString(title)},
82
+ description: ${jsString(description)},
83
+ icon: "icon.png",
84
+ menu: async () => [
85
+ { default: true, text: "Start", href: "start.js" },
86
+ { text: "Custom", href: "custom.launch.js" },
87
+ { text: "Update", href: "update.js" },
88
+ { text: "Reset", href: "reset.js" }
89
+ ]
90
+ }
91
+ `
92
+ }
93
+
94
+ function waitScript(label, seconds = 0.75, daemon = true) {
95
+ return `module.exports = {
96
+ daemon: ${daemon ? "true" : "false"},
97
+ run: [
98
+ { method: "process.wait", params: { sec: ${Number(seconds)} } },
99
+ { method: "local.set", params: { marker: ${jsString(label)} } }
100
+ ]
101
+ }
102
+ `
103
+ }
104
+
105
+ function envFile(values) {
106
+ return Object.entries(values)
107
+ .map(([key, value]) => `${key}=${value}`)
108
+ .join("\n") + "\n"
109
+ }
110
+
111
+ async function writeApp(home, app) {
112
+ const dir = path.resolve(home, "api", app.id)
113
+ await fsp.mkdir(dir, { recursive: true })
114
+ await fsp.writeFile(path.resolve(dir, "pinokio.js"), appModule(app.title, app.description || `${app.title} acceptance fixture.`))
115
+ await fsp.writeFile(path.resolve(dir, "icon.png"), Buffer.from(
116
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
117
+ "base64"
118
+ ))
119
+ const scripts = app.scripts || {
120
+ "start.js": waitScript(`${app.id}:start`, app.waitSeconds || 0.75, true),
121
+ "custom.launch.js": waitScript(`${app.id}:custom`, app.waitSeconds || 0.35, true),
122
+ "update.js": waitScript(`${app.id}:update`, 0.2, false),
123
+ "reset.js": waitScript(`${app.id}:reset`, 0.2, false)
124
+ }
125
+ for (const [name, content] of Object.entries(scripts)) {
126
+ await fsp.writeFile(path.resolve(dir, name), content)
127
+ }
128
+ if (app.env) {
129
+ await fsp.writeFile(path.resolve(dir, "ENVIRONMENT"), envFile(app.env))
130
+ }
131
+ return dir
132
+ }
133
+
134
+ async function createHome(apps) {
135
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), "pinokio-real-browser-"))
136
+ const fakeUserHome = path.resolve(root, "user")
137
+ const pinokioHome = path.resolve(root, "pinokio")
138
+ await fsp.mkdir(path.resolve(pinokioHome, "api"), { recursive: true })
139
+ await fsp.mkdir(fakeUserHome, { recursive: true })
140
+ const runtimeBin = path.resolve(process.env.PINOKIO_REAL_BROWSER_BIN || path.join(os.homedir(), "pinokio", "bin"))
141
+ if (fs.existsSync(runtimeBin)) {
142
+ await fsp.symlink(runtimeBin, path.resolve(pinokioHome, "bin"), "dir")
143
+ }
144
+ for (const app of apps) {
145
+ await writeApp(pinokioHome, app)
146
+ }
147
+ return { root, fakeUserHome, pinokioHome }
148
+ }
149
+
150
+ async function runServerMode() {
151
+ const args = parseArgs()
152
+ const port = Number(args.port)
153
+ const home = path.resolve(String(args.home || ""))
154
+ const pkg = require(path.resolve(repoRoot, "package.json"))
155
+ const Server = require(path.resolve(repoRoot, "server"))
156
+ const server = new Server({
157
+ store: { store: { home, version: pkg.version } },
158
+ agent: "test",
159
+ newsfeed: "",
160
+ portal: ""
161
+ })
162
+ server.port = port
163
+ await server.start({ debug: true })
164
+ process.stdout.write(`${readyPrefix}${JSON.stringify({ port, home, pid: process.pid })}\n`)
165
+ setInterval(() => {}, 1000)
166
+ }
167
+
168
+ function waitForReady(child, timeoutMs = 60000) {
169
+ return new Promise((resolve, reject) => {
170
+ let stdout = ""
171
+ let stderr = ""
172
+ const timer = setTimeout(() => {
173
+ reject(new Error(`server did not become ready in ${timeoutMs}ms\nstdout:\n${stdout}\nstderr:\n${stderr}`))
174
+ }, timeoutMs)
175
+ child.stdout.on("data", (chunk) => {
176
+ const text = chunk.toString()
177
+ stdout += text
178
+ for (const line of text.split(/\r?\n/)) {
179
+ if (line.startsWith(readyPrefix)) {
180
+ clearTimeout(timer)
181
+ resolve({ ready: JSON.parse(line.slice(readyPrefix.length)), stdout, stderr })
182
+ }
183
+ }
184
+ })
185
+ child.stderr.on("data", (chunk) => {
186
+ stderr += chunk.toString()
187
+ })
188
+ child.on("exit", (code, signal) => {
189
+ clearTimeout(timer)
190
+ reject(new Error(`server exited before ready: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
191
+ })
192
+ })
193
+ }
194
+
195
+ async function waitForHttp(url, timeoutMs = 30000) {
196
+ const start = Date.now()
197
+ while (Date.now() - start < timeoutMs) {
198
+ try {
199
+ const response = await fetch(url)
200
+ if (response.ok) {
201
+ return
202
+ }
203
+ } catch (_) {}
204
+ await new Promise((resolve) => setTimeout(resolve, 250))
205
+ }
206
+ throw new Error(`timed out waiting for ${url}`)
207
+ }
208
+
209
+ async function withRealPinokio(apps, scenarioName, callback) {
210
+ const home = await createHome(apps)
211
+ const port = await freePort()
212
+ const artifacts = path.resolve(outputRoot, `${Date.now()}-${scenarioName}`)
213
+ await fsp.mkdir(artifacts, { recursive: true })
214
+ const env = {
215
+ ...process.env,
216
+ HOME: home.fakeUserHome,
217
+ PINOKIO_HOME: home.pinokioHome,
218
+ PINOKIO_DISABLE_WATCH: "1"
219
+ }
220
+ const child = childProcess.spawn(process.execPath, [
221
+ __filename,
222
+ "--server",
223
+ "--port",
224
+ String(port),
225
+ "--home",
226
+ home.pinokioHome
227
+ ], {
228
+ cwd: repoRoot,
229
+ env,
230
+ detached: true,
231
+ stdio: ["ignore", "pipe", "pipe"]
232
+ })
233
+ const logs = []
234
+ child.stdout.on("data", (chunk) => logs.push(chunk.toString()))
235
+ child.stderr.on("data", (chunk) => logs.push(chunk.toString()))
236
+ const playwright = loadPlaywright()
237
+ const executablePath = chromiumExecutablePath(playwright)
238
+ assert.ok(executablePath, "No Chromium executable is available for real-browser acceptance")
239
+ let browser
240
+ let page
241
+ try {
242
+ await waitForReady(child)
243
+ const baseUrl = `http://127.0.0.1:${port}`
244
+ await waitForHttp(`${baseUrl}/home`)
245
+ browser = await playwright.chromium.launch({ headless: true, executablePath })
246
+ page = await browser.newPage()
247
+ page.setDefaultTimeout(15000)
248
+ const pageErrors = []
249
+ page.on("pageerror", (error) => pageErrors.push(error.message || String(error)))
250
+ await callback({ page, baseUrl, artifacts, home, pageErrors })
251
+ assert.deepEqual(pageErrors, [])
252
+ await fsp.writeFile(path.resolve(artifacts, "server.log"), logs.join(""))
253
+ return { artifacts, home }
254
+ } catch (error) {
255
+ if (page) {
256
+ await page.screenshot({ path: path.resolve(artifacts, "failure.png"), fullPage: true }).catch(() => {})
257
+ }
258
+ await fsp.writeFile(path.resolve(artifacts, "server.log"), logs.join("")).catch(() => {})
259
+ throw error
260
+ } finally {
261
+ if (page) {
262
+ await page.close().catch(() => {})
263
+ }
264
+ if (browser) {
265
+ await browser.close().catch(() => {})
266
+ }
267
+ try {
268
+ process.kill(-child.pid, "SIGTERM")
269
+ } catch (_) {}
270
+ await new Promise((resolve) => {
271
+ const timer = setTimeout(resolve, 3000)
272
+ child.once("exit", () => {
273
+ clearTimeout(timer)
274
+ resolve()
275
+ })
276
+ })
277
+ try {
278
+ process.kill(-child.pid, "SIGKILL")
279
+ } catch (_) {}
280
+ }
281
+ }
282
+
283
+ async function screenshot(page, artifacts, name) {
284
+ const file = path.resolve(artifacts, `${name}.png`)
285
+ await page.screenshot({ path: file, fullPage: true })
286
+ return file
287
+ }
288
+
289
+ async function bodyText(page) {
290
+ return await page.locator("body").innerText()
291
+ }
292
+
293
+ async function homeRows(page) {
294
+ return await page.evaluate(() => {
295
+ return Array.from(document.querySelectorAll(".home-app-line")).map((line) => ({
296
+ app: line.getAttribute("data-autolaunch-app") || line.getAttribute("data-uri") || "",
297
+ name: line.getAttribute("data-name") || line.getAttribute("data-title") || "",
298
+ section: line.closest(".running-apps") ? "running" : "installed",
299
+ text: line.innerText,
300
+ stopButtons: Array.from(line.querySelectorAll(".shutdown")).map((button) => button.innerText.trim()),
301
+ status: Array.from(line.querySelectorAll(".home-autolaunch-status")).map((chip) => chip.innerText.trim())
302
+ }))
303
+ })
304
+ }
305
+
306
+ function findRow(rows, name) {
307
+ return rows.find((row) => row.name === name || row.text.includes(name))
308
+ }
309
+
310
+ async function waitForHomeRow(page, name, predicate, timeoutMs = 15000) {
311
+ const start = Date.now()
312
+ let lastRows = []
313
+ while (Date.now() - start < timeoutMs) {
314
+ lastRows = await homeRows(page)
315
+ const row = findRow(lastRows, name)
316
+ if (row && (!predicate || predicate(row, lastRows))) {
317
+ return row
318
+ }
319
+ await page.waitForTimeout(250)
320
+ }
321
+ throw new Error(`timed out waiting for home row ${name}; rows=${JSON.stringify(lastRows, null, 2)}`)
322
+ }
323
+
324
+ async function launchRequirementsStatus(page) {
325
+ return await page.evaluate(async () => {
326
+ const match = location.pathname.match(/^\/api\/([^/]+)/)
327
+ if (!match) {
328
+ return null
329
+ }
330
+ const response = await fetch(`/pinokio/launch-requirements/${encodeURIComponent(match[1])}?t=${Date.now()}`)
331
+ return await response.json()
332
+ })
333
+ }
334
+
335
+ async function homeStatus(page) {
336
+ return await page.evaluate(async () => {
337
+ const response = await fetch(`/pinokio/home_status?t=${Date.now()}`, {
338
+ headers: { "Accept": "application/json" },
339
+ cache: "no-store"
340
+ })
341
+ return await response.json()
342
+ })
343
+ }
344
+
345
+ async function waitForRunningScript(page, appId, script = "start.js", timeoutMs = 30000) {
346
+ const start = Date.now()
347
+ let lastStatus = null
348
+ while (Date.now() - start < timeoutMs) {
349
+ lastStatus = await homeStatus(page)
350
+ if (hasRunningScript(lastStatus, appId, script)) {
351
+ return lastStatus
352
+ }
353
+ await page.waitForTimeout(250)
354
+ }
355
+ throw new Error(`timed out waiting for ${appId}/${script} to run; home_status=${JSON.stringify(lastStatus, null, 2)}`)
356
+ }
357
+
358
+ function hasRunningScript(status, appId, script = "start.js") {
359
+ return Array.isArray(status && status.running_scripts) && status.running_scripts.some((entry) => {
360
+ return entry && entry.app === appId && entry.script_path === script
361
+ })
362
+ }
363
+
364
+ async function scenarioNoEnvBaseline() {
365
+ const apps = [{
366
+ id: "codex-real-plain",
367
+ title: "Codex Real Plain",
368
+ waitSeconds: 0.2
369
+ }]
370
+ return await withRealPinokio(apps, "no-env-baseline", async ({ page, baseUrl, artifacts }) => {
371
+ await page.goto(`${baseUrl}/api/codex-real-plain/custom.launch.js`, { waitUntil: "domcontentloaded" })
372
+ await page.locator(".run .stop").waitFor({ state: "visible", timeout: 15000 })
373
+ await screenshot(page, artifacts, "plain-custom-launch")
374
+ const text = await bodyText(page)
375
+ assert.doesNotMatch(text, /Preparing required apps/i)
376
+ assert.doesNotMatch(text, /Disconnected/i)
377
+ await page.goto(`${baseUrl}/home`, { waitUntil: "domcontentloaded" })
378
+ await waitForHomeRow(page, "Codex Real Plain", (row) => row.stopButtons.some((label) => /Stop custom\.launch\.js/.test(label)))
379
+ })
380
+ }
381
+
382
+ function recursiveApps(rootStartupEnabled = true) {
383
+ return [
384
+ {
385
+ id: "codex-real-root",
386
+ title: "Codex Real Root",
387
+ waitSeconds: 0.6,
388
+ env: {
389
+ PINOKIO_SCRIPT_AUTOLAUNCH: "start.js",
390
+ PINOKIO_SCRIPT_AUTOLAUNCH_ENABLED: rootStartupEnabled ? "true" : "false",
391
+ PINOKIO_SCRIPT_REQUIRES: "codex-real-middle"
392
+ }
393
+ },
394
+ {
395
+ id: "codex-real-middle",
396
+ title: "Codex Real Middle",
397
+ waitSeconds: 0.8,
398
+ env: {
399
+ PINOKIO_SCRIPT_AUTOLAUNCH: "start.js",
400
+ PINOKIO_SCRIPT_AUTOLAUNCH_ENABLED: "false",
401
+ PINOKIO_SCRIPT_REQUIRES: "codex-real-leaf"
402
+ }
403
+ },
404
+ {
405
+ id: "codex-real-leaf",
406
+ title: "Codex Real Leaf",
407
+ waitSeconds: 1.2,
408
+ env: {
409
+ PINOKIO_SCRIPT_AUTOLAUNCH: "start.js",
410
+ PINOKIO_SCRIPT_AUTOLAUNCH_ENABLED: "false"
411
+ }
412
+ }
413
+ ]
414
+ }
415
+
416
+ async function scenarioStartupRecursiveHome() {
417
+ return await withRealPinokio(recursiveApps(true), "startup-recursive-home", async ({ page, baseUrl, artifacts }) => {
418
+ await page.goto(`${baseUrl}/home`, { waitUntil: "domcontentloaded" })
419
+ await screenshot(page, artifacts, "home-initial")
420
+ await waitForHomeRow(page, "Codex Real Root", (row) => /Waiting for Codex Real Middle|Starting start\.js|Stop start\.js/.test(row.text))
421
+ await waitForHomeRow(page, "Codex Real Middle", (row) => /Waiting for Codex Real Leaf|Starting start\.js|Stop start\.js/.test(row.text))
422
+ await waitForHomeRow(page, "Codex Real Leaf", (row) => /Starting start\.js|Stop start\.js/.test(row.text))
423
+ await screenshot(page, artifacts, "home-recursive-status")
424
+ await waitForHomeRow(page, "Codex Real Root", (row) => row.stopButtons.some((label) => /Stop start\.js/.test(label)), 30000)
425
+ await waitForHomeRow(page, "Codex Real Middle", (row) => row.stopButtons.some((label) => /Stop start\.js/.test(label)), 30000)
426
+ await waitForHomeRow(page, "Codex Real Leaf", (row) => row.stopButtons.some((label) => /Stop start\.js/.test(label)), 30000)
427
+ await screenshot(page, artifacts, "home-all-running")
428
+ })
429
+ }
430
+
431
+ async function scenarioManualRequirementsStopLaunch() {
432
+ return await withRealPinokio(recursiveApps(false), "manual-requirements-stop-launch", async ({ page, baseUrl, artifacts }) => {
433
+ await page.goto(`${baseUrl}/api/codex-real-root/start.js`, { waitUntil: "domcontentloaded" })
434
+ await waitForHomeText(page, /Preparing required apps/i)
435
+ await waitForHomeText(page, /Waiting for Codex Real Leaf|Waiting for Codex Real Middle/i)
436
+ await screenshot(page, artifacts, "manual-preparing")
437
+ const text = await bodyText(page)
438
+ assert.doesNotMatch(text, /Disconnected/i)
439
+ const stop = page.getByRole("button", { name: /Stop launch/i })
440
+ await stop.click()
441
+ await page.waitForFunction(() => {
442
+ const body = document.body ? document.body.innerText : ""
443
+ return !/Preparing required apps|Launch stopped|Stopped launch/i.test(body)
444
+ }, null, { timeout: 15000 })
445
+ await screenshot(page, artifacts, "manual-launch-cleared")
446
+ const status = await launchRequirementsStatus(page)
447
+ assert.ok(status && status.ok)
448
+ assert.equal(status.status, null)
449
+ const runtime = await homeStatus(page)
450
+ assert.equal(Array.isArray(runtime.running_apps) && runtime.running_apps.includes("codex-real-root"), false)
451
+ assert.equal(hasRunningScript(runtime, "codex-real-root"), false)
452
+ })
453
+ }
454
+
455
+ async function scenarioStartupAppStopTarget() {
456
+ const apps = recursiveApps(true).map((app) => (
457
+ app.id === "codex-real-leaf" ? { ...app, waitSeconds: 8 } : app
458
+ ))
459
+ return await withRealPinokio(apps, "startup-app-stop-target", async ({ page, baseUrl, artifacts }) => {
460
+ await page.goto(`${baseUrl}/home`, { waitUntil: "domcontentloaded" })
461
+ await waitForHomeRow(page, "Codex Real Root", (row) => {
462
+ return /Waiting for Codex Real Middle|Starting start\.js|Preparing/i.test(row.text)
463
+ }, 15000)
464
+ await screenshot(page, artifacts, "startup-home-root-preparing")
465
+
466
+ await page.locator(".home-app-line", { hasText: "Codex Real Root" }).first().click()
467
+ await page.getByRole("button", { name: /Stop launch/i }).waitFor({ state: "visible", timeout: 15000 })
468
+ await screenshot(page, artifacts, "startup-root-app-preparing")
469
+ await page.getByRole("button", { name: /Stop launch/i }).click()
470
+ await page.waitForFunction(() => {
471
+ const body = document.body ? document.body.innerText : ""
472
+ return !/Preparing required apps|Launch stopped|Stopped launch/i.test(body)
473
+ }, null, { timeout: 15000 })
474
+ await screenshot(page, artifacts, "startup-root-app-cleared")
475
+
476
+ const appPageRuntime = await homeStatus(page)
477
+ assert.equal(Array.isArray(appPageRuntime.running_apps) && appPageRuntime.running_apps.includes("codex-real-root"), false)
478
+ assert.equal(hasRunningScript(appPageRuntime, "codex-real-root"), false)
479
+
480
+ await page.goto(`${baseUrl}/home`, { waitUntil: "domcontentloaded" })
481
+ try {
482
+ await waitForHomeRow(page, "Codex Real Root", (row) => {
483
+ return row.section !== "running" &&
484
+ row.stopButtons.length === 0 &&
485
+ !/Starting start\.js|Waiting for Codex Real Middle|Stop start\.js/i.test(row.text)
486
+ }, 30000)
487
+ } catch (error) {
488
+ const runtime = await homeStatus(page)
489
+ const status = await page.evaluate(async () => {
490
+ const response = await fetch(`/pinokio/launch-requirements/codex-real-root?t=${Date.now()}`)
491
+ return await response.json()
492
+ })
493
+ throw new Error(`${error.message}\nhome_status=${JSON.stringify(runtime, null, 2)}\nlaunch_status=${JSON.stringify(status, null, 2)}`)
494
+ }
495
+ await screenshot(page, artifacts, "startup-home-root-not-running")
496
+
497
+ const runtime = await homeStatus(page)
498
+ assert.equal(Array.isArray(runtime.running_apps) && runtime.running_apps.includes("codex-real-root"), false)
499
+ assert.equal(hasRunningScript(runtime, "codex-real-root"), false)
500
+
501
+ await page.locator(".home-app-line", { hasText: "Codex Real Root" }).first().click()
502
+ await waitForRunningScript(page, "codex-real-root", "start.js", 45000)
503
+ await screenshot(page, artifacts, "startup-root-reopened-default-running")
504
+ })
505
+ }
506
+
507
+ async function waitForHomeText(page, pattern, timeoutMs = 15000) {
508
+ const start = Date.now()
509
+ let text = ""
510
+ while (Date.now() - start < timeoutMs) {
511
+ text = await bodyText(page)
512
+ if (pattern.test(text)) {
513
+ return text
514
+ }
515
+ await page.waitForTimeout(250)
516
+ }
517
+ throw new Error(`timed out waiting for text ${pattern}; body=${text}`)
518
+ }
519
+
520
+ async function scenarioHomeStopNoReload() {
521
+ const apps = [{
522
+ id: "codex-real-stop",
523
+ title: "Codex Real Stop",
524
+ waitSeconds: 0.2
525
+ }]
526
+ return await withRealPinokio(apps, "home-stop-no-reload", async ({ page, baseUrl, artifacts }) => {
527
+ await page.addInitScript(() => {
528
+ const value = Number(window.localStorage.getItem("codexHomeLoadCount") || "0") + 1
529
+ window.localStorage.setItem("codexHomeLoadCount", String(value))
530
+ })
531
+ await page.goto(`${baseUrl}/api/codex-real-stop/start.js`, { waitUntil: "domcontentloaded" })
532
+ await page.locator(".run .stop").waitFor({ state: "visible", timeout: 15000 })
533
+ await page.goto(`${baseUrl}/home`, { waitUntil: "domcontentloaded" })
534
+ const beforeLoads = await page.evaluate(() => window.localStorage.getItem("codexHomeLoadCount"))
535
+ const row = await waitForHomeRow(page, "Codex Real Stop", (candidate) => candidate.stopButtons.some((label) => /Stop start\.js/.test(label)))
536
+ assert.equal(row.section, "running")
537
+ await screenshot(page, artifacts, "home-before-stop")
538
+ await page.locator(".home-app-line", { hasText: "Codex Real Stop" }).locator(".shutdown").first().click()
539
+ await waitForHomeRow(page, "Codex Real Stop", (candidate) => candidate.stopButtons.length === 0 && candidate.section !== "running", 15000)
540
+ const afterLoads = await page.evaluate(() => window.localStorage.getItem("codexHomeLoadCount"))
541
+ assert.equal(afterLoads, beforeLoads, "Home stop caused a full page reload")
542
+ await screenshot(page, artifacts, "home-after-stop")
543
+ })
544
+ }
545
+
546
+ async function scenarioRequirementStartedNormalStop() {
547
+ const apps = recursiveApps(false).map((app) => (
548
+ app.id === "codex-real-leaf" ? { ...app, waitSeconds: 8 } : app
549
+ ))
550
+ return await withRealPinokio(apps, "requirement-started-normal-stop", async ({ page, baseUrl, artifacts }) => {
551
+ await page.goto(`${baseUrl}/api/codex-real-root/start.js`, { waitUntil: "domcontentloaded" })
552
+ await waitForHomeText(page, /Preparing required apps/i)
553
+ await waitForHomeText(page, /Codex Real Leaf/i)
554
+ await screenshot(page, artifacts, "root-preparing-before-leaf-stop")
555
+
556
+ await page.goto(`${baseUrl}/api/codex-real-leaf/start.js`, { waitUntil: "domcontentloaded" })
557
+ await page.locator(".run .stop").waitFor({ state: "visible", timeout: 15000 })
558
+ await screenshot(page, artifacts, "leaf-before-normal-stop")
559
+ await page.locator(".run .stop").click()
560
+ await page.waitForTimeout(1200)
561
+ await screenshot(page, artifacts, "leaf-after-normal-stop")
562
+
563
+ await page.goto(`${baseUrl}/home`, { waitUntil: "domcontentloaded" })
564
+ await waitForHomeRow(page, "Codex Real Leaf", (row) => {
565
+ return row.stopButtons.length === 0 && !/Starting start\.js|Stop start\.js/.test(row.text)
566
+ }, 15000)
567
+ const rows = await homeRows(page)
568
+ const root = findRow(rows, "Codex Real Root")
569
+ const middle = findRow(rows, "Codex Real Middle")
570
+ const leaf = findRow(rows, "Codex Real Leaf")
571
+ assert.ok(root, "root row should remain visible as an installed app")
572
+ assert.ok(middle, "middle row should remain visible as an installed app")
573
+ assert.ok(leaf, "leaf row should remain visible as an installed app")
574
+ assert.equal(root.stopButtons.some((label) => /Stop start\.js/.test(label)), false)
575
+ assert.equal(middle.stopButtons.some((label) => /Stop start\.js/.test(label)), false)
576
+ assert.equal(leaf.stopButtons.some((label) => /Stop start\.js/.test(label)), false)
577
+ assert.doesNotMatch(root.text, /Starting start\.js|Waiting for Codex Real Middle/)
578
+ assert.doesNotMatch(middle.text, /Starting start\.js|Waiting for Codex Real Leaf/)
579
+ assert.doesNotMatch(leaf.text, /Starting start\.js/)
580
+ await screenshot(page, artifacts, "home-after-leaf-normal-stop")
581
+ })
582
+ }
583
+
584
+ const scenarios = {
585
+ "no-env-baseline": scenarioNoEnvBaseline,
586
+ "startup-recursive-home": scenarioStartupRecursiveHome,
587
+ "manual-requirements-stop-launch": scenarioManualRequirementsStopLaunch,
588
+ "startup-app-stop-target": scenarioStartupAppStopTarget,
589
+ "home-stop-no-reload": scenarioHomeStopNoReload,
590
+ "requirement-started-normal-stop": scenarioRequirementStartedNormalStop
591
+ }
592
+
593
+ async function runParentMode() {
594
+ const args = parseArgs()
595
+ const only = args.case ? String(args.case).split(",").map((item) => item.trim()).filter(Boolean) : Object.keys(scenarios)
596
+ const unknown = only.filter((name) => !scenarios[name])
597
+ assert.deepEqual(unknown, [], `Unknown real-browser scenario(s): ${unknown.join(", ")}`)
598
+ await fsp.mkdir(outputRoot, { recursive: true })
599
+ const results = []
600
+ for (const name of only) {
601
+ const started = Date.now()
602
+ try {
603
+ const result = await scenarios[name]()
604
+ results.push({ name, ok: true, ms: Date.now() - started, artifacts: result.artifacts })
605
+ process.stdout.write(`${resultPrefix}${JSON.stringify(results[results.length - 1])}\n`)
606
+ } catch (error) {
607
+ results.push({ name, ok: false, ms: Date.now() - started, error: error && error.stack ? error.stack : String(error) })
608
+ process.stdout.write(`${resultPrefix}${JSON.stringify(results[results.length - 1])}\n`)
609
+ throw error
610
+ }
611
+ }
612
+ process.stdout.write(`${resultPrefix}${JSON.stringify({ ok: true, results })}\n`)
613
+ }
614
+
615
+ if (serverMode) {
616
+ runServerMode().catch((error) => {
617
+ console.error(error && error.stack ? error.stack : error)
618
+ process.exit(1)
619
+ })
620
+ } else {
621
+ runParentMode().catch((error) => {
622
+ console.error(error && error.stack ? error.stack : error)
623
+ process.exit(1)
624
+ })
625
+ }