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,1806 @@
1
+ const assert = require("node:assert/strict")
2
+ const fs = require("node:fs/promises")
3
+ const os = require("node:os")
4
+ const path = require("node:path")
5
+ const test = require("node:test")
6
+
7
+ const LaunchRequirements = require("../kernel/launch_requirements")
8
+ const Environment = require("../kernel/environment")
9
+ const Autolaunch = require("../kernel/autolaunch")
10
+ const Api = require("../kernel/api")
11
+ const Kernel = require("../kernel")
12
+
13
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
14
+ const waitFor = async (predicate, timeoutMs = 500) => {
15
+ const startedAt = Date.now()
16
+ while (Date.now() - startedAt < timeoutMs) {
17
+ if (predicate()) {
18
+ return true
19
+ }
20
+ await delay(5)
21
+ }
22
+ return false
23
+ }
24
+
25
+ async function withFixtureApps(defs, options, fn) {
26
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "pinokio-launch-requirements-"))
27
+ const homedir = path.resolve(root, "home")
28
+ const apiRoot = path.resolve(homedir, "api")
29
+ await fs.mkdir(apiRoot, { recursive: true })
30
+
31
+ for (const [id, def] of Object.entries(defs)) {
32
+ const appRoot = path.resolve(apiRoot, id)
33
+ await fs.mkdir(appRoot, { recursive: true })
34
+ const script = def.noScript ? "" : (def.script || "start.js")
35
+ const configuredScript = def.noLaunchConfig ? "" : script
36
+ const env = [
37
+ `PINOKIO_SCRIPT_AUTOLAUNCH=${configuredScript}`,
38
+ Object.prototype.hasOwnProperty.call(def, "startup")
39
+ ? `${Environment.SCRIPT_AUTOLAUNCH_ENABLED_KEY}=${def.startup ? "true" : "false"}`
40
+ : "",
41
+ def.legacyDeps
42
+ ? `PINOKIO_SCRIPT_AUTOLAUNCH_DEPENDS=${(def.deps || []).join(",")}`
43
+ : `${Environment.SCRIPT_REQUIREMENTS_KEY}=${(def.deps || []).join(",")}`,
44
+ ""
45
+ ].filter((line) => line !== null && line !== undefined).join("\n")
46
+ await fs.writeFile(path.resolve(appRoot, "ENVIRONMENT"), env, "utf8")
47
+ if (script) {
48
+ await fs.writeFile(path.resolve(appRoot, script), "module.exports = { run: [] }\n", "utf8")
49
+ }
50
+ }
51
+
52
+ const apps = Object.keys(defs).map((id) => ({
53
+ id,
54
+ title: defs[id].title || id,
55
+ icon: `/icon/${id}.png`
56
+ }))
57
+ const running = {}
58
+ const ready = new Set()
59
+ const starts = []
60
+ const events = []
61
+ const startDelayMs = Number(options && options.startDelayMs) || 0
62
+ const readyDelayMs = Number(options && options.readyDelayMs) || 0
63
+ const readyAfterStart = !(options && options.readyAfterStart === false)
64
+ let processFailuresBeforeSuccess = Number(options && options.processFailuresBeforeSuccess) || 0
65
+
66
+ const kernel = {
67
+ homedir,
68
+ exists: async (filepath) => {
69
+ try {
70
+ await fs.stat(filepath)
71
+ return true
72
+ } catch (_) {
73
+ return false
74
+ }
75
+ },
76
+ path: (name, ...chunks) => path.resolve(homedir, name, ...chunks),
77
+ normalizeAppId: (value) => {
78
+ if (typeof value !== "string") return ""
79
+ const id = value.trim()
80
+ return id && !/[\\/]/.test(id) ? id : ""
81
+ },
82
+ getAppIdForLaunchPath: (launchPath) => {
83
+ const relative = path.relative(apiRoot, path.resolve(launchPath))
84
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return ""
85
+ return relative.split(path.sep)[0] || ""
86
+ },
87
+ getAppRelativeLaunchScript: (appId, launchPath) => {
88
+ const relative = path.relative(path.resolve(apiRoot, appId), path.resolve(launchPath))
89
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return ""
90
+ return relative.split(path.sep).join("/")
91
+ },
92
+ readyState: {
93
+ markStarted: () => ({ state: "running" }),
94
+ markProgress: (_launchPath, current, total) => ({ step_current: current, step_total: total }),
95
+ markReady: (scriptPath) => {
96
+ ready.add(path.resolve(scriptPath))
97
+ return { state: "ready" }
98
+ },
99
+ markFailed: () => ({ state: "failed" }),
100
+ markStopped: () => ({ state: "stopped" }),
101
+ getAppIdForLaunchPath: (launchPath) => {
102
+ const relative = path.relative(apiRoot, path.resolve(launchPath))
103
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return ""
104
+ return relative.split(path.sep)[0] || ""
105
+ }
106
+ },
107
+ hasLaunchRequirementRuntime: Kernel.prototype.hasLaunchRequirementRuntime,
108
+ markAppLaunchStarted: Kernel.prototype.markAppLaunchStarted,
109
+ markAppLaunchReady: Kernel.prototype.markAppLaunchReady,
110
+ markAppLaunchStopped: Kernel.prototype.markAppLaunchStopped,
111
+ isScriptReady: (scriptPath) => ready.has(path.resolve(scriptPath)),
112
+ getScriptProgress: () => null,
113
+ api: {
114
+ userdir: apiRoot,
115
+ running,
116
+ ondata: (packet) => {
117
+ events.push(packet)
118
+ },
119
+ listApps: async () => apps,
120
+ process: async ({ uri }) => {
121
+ if (processFailuresBeforeSuccess > 0) {
122
+ processFailuresBeforeSuccess -= 1
123
+ throw new Error("process failed")
124
+ }
125
+ if (startDelayMs > 0) {
126
+ await delay(startDelayMs)
127
+ }
128
+ const resolved = path.resolve(uri)
129
+ starts.push({
130
+ uri: resolved,
131
+ app: path.relative(apiRoot, resolved).split(path.sep)[0],
132
+ at: Date.now()
133
+ })
134
+ running[resolved] = true
135
+ if (options && options.simulateKernelLifecycle) {
136
+ kernel.markAppLaunchStarted(resolved)
137
+ }
138
+ if (readyAfterStart) {
139
+ setTimeout(() => {
140
+ if (options && options.simulateKernelLifecycle) {
141
+ delete running[resolved]
142
+ kernel.markAppLaunchStopped(resolved, { internal_completion: true })
143
+ kernel.markAppLaunchReady(resolved)
144
+ } else {
145
+ ready.add(resolved)
146
+ }
147
+ }, readyDelayMs)
148
+ }
149
+ }
150
+ }
151
+ }
152
+ const resolver = new LaunchRequirements(kernel)
153
+ kernel.launchRequirements = resolver
154
+
155
+ try {
156
+ await fn({
157
+ root,
158
+ homedir,
159
+ apiRoot,
160
+ kernel,
161
+ resolver,
162
+ starts,
163
+ events,
164
+ running,
165
+ ready,
166
+ launchPath: (appId, script = "start.js") => path.resolve(apiRoot, appId, script),
167
+ markReady: (appId, script = "start.js") => ready.add(path.resolve(apiRoot, appId, script))
168
+ })
169
+ } finally {
170
+ await fs.rm(root, { recursive: true, force: true })
171
+ }
172
+ }
173
+
174
+ async function withApiProcessFixture(requirementResult, fn, options = {}) {
175
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "pinokio-api-requirements-"))
176
+ const homedir = path.resolve(root, "home")
177
+ const apiRoot = path.resolve(homedir, "api")
178
+ const appRoot = path.resolve(apiRoot, "target")
179
+ const launchPath = path.resolve(appRoot, "start.js")
180
+ await fs.mkdir(appRoot, { recursive: true })
181
+ await fs.writeFile(launchPath, "module.exports = { run: [] }\n", "utf8")
182
+
183
+ let requirementCalls = 0
184
+ const hasRequirementConfig = Object.prototype.hasOwnProperty.call(options, "hasRequirementConfig")
185
+ ? !!options.hasRequirementConfig
186
+ : true
187
+ const kernel = {
188
+ homedir,
189
+ shell: { init: async () => {} },
190
+ path: (name, ...chunks) => path.resolve(homedir, name, ...chunks),
191
+ hasLaunchRequirementConfig: async () => hasRequirementConfig,
192
+ ensureLaunchRequirements: async () => {
193
+ requirementCalls += 1
194
+ return requirementResult
195
+ },
196
+ dns: async () => {}
197
+ }
198
+ const api = new Api(kernel)
199
+ kernel.api = api
200
+ api.userdir = apiRoot
201
+ const packets = []
202
+ let setRunningCalls = 0
203
+ api.listen("test", (packet) => packets.push(packet))
204
+ api.resolvePath = () => launchPath
205
+ api.resolveScript = async () => ({
206
+ cwd: appRoot,
207
+ script: { run: [] }
208
+ })
209
+ api.isActionCandidate = () => true
210
+ api.setRunning = () => {
211
+ setRunningCalls += 1
212
+ }
213
+ api.resolveActionSteps = async () => []
214
+
215
+ try {
216
+ await fn({ api, packets, launchPath, setRunningCalls: () => setRunningCalls, requirementCalls: () => requirementCalls })
217
+ } finally {
218
+ await fs.rm(root, { recursive: true, force: true })
219
+ }
220
+ }
221
+
222
+ async function withApiBridgeFixture(fn, options = {}) {
223
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "pinokio-api-bridge-requirements-"))
224
+ const homedir = path.resolve(root, "home")
225
+ const apiRoot = path.resolve(homedir, "api")
226
+ const helperRoot = path.resolve(apiRoot, "helper")
227
+ const targetRoot = path.resolve(apiRoot, "target")
228
+ const ready = new Set()
229
+ const events = []
230
+ const queued = []
231
+ const targetScript = options.targetScript || "start.js"
232
+ await fs.mkdir(helperRoot, { recursive: true })
233
+ await fs.mkdir(targetRoot, { recursive: true })
234
+ await fs.writeFile(path.resolve(helperRoot, "start.js"), "module.exports = { run: [] }\n", "utf8")
235
+ await fs.writeFile(path.resolve(targetRoot, targetScript), "module.exports = { run: [] }\n", "utf8")
236
+ await fs.writeFile(path.resolve(helperRoot, "ENVIRONMENT"), [
237
+ "PINOKIO_SCRIPT_AUTOLAUNCH=start.js",
238
+ "PINOKIO_SCRIPT_AUTOLAUNCH_ENABLED=false",
239
+ "PINOKIO_SCRIPT_REQUIRES=",
240
+ ""
241
+ ].join("\n"), "utf8")
242
+ await fs.writeFile(path.resolve(targetRoot, "ENVIRONMENT"), [
243
+ `PINOKIO_SCRIPT_AUTOLAUNCH=${targetScript}`,
244
+ "PINOKIO_SCRIPT_AUTOLAUNCH_ENABLED=false",
245
+ "PINOKIO_SCRIPT_REQUIRES=helper",
246
+ ""
247
+ ].join("\n"), "utf8")
248
+
249
+ const kernel = {
250
+ homedir,
251
+ shell: { init: async () => {} },
252
+ path: (name, ...chunks) => path.resolve(homedir, name, ...chunks),
253
+ exists: async (filepath) => {
254
+ try {
255
+ await fs.stat(filepath)
256
+ return true
257
+ } catch (_) {
258
+ return false
259
+ }
260
+ },
261
+ normalizeAppId: (value) => {
262
+ if (typeof value !== "string") return ""
263
+ const id = value.trim()
264
+ return id && !/[\\/]/.test(id) ? id : ""
265
+ },
266
+ getAppIdForLaunchPath: (launchPath) => {
267
+ const relative = path.relative(apiRoot, path.resolve(launchPath))
268
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return ""
269
+ return relative.split(path.sep)[0] || ""
270
+ },
271
+ getAppRelativeLaunchScript: (appId, launchPath) => {
272
+ const relative = path.relative(path.resolve(apiRoot, appId), path.resolve(launchPath))
273
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return ""
274
+ return relative.split(path.sep).join("/")
275
+ },
276
+ isScriptReady: (scriptPath) => ready.has(path.resolve(scriptPath)),
277
+ getScriptProgress: () => null,
278
+ dns: async () => {}
279
+ }
280
+ const api = new Api(kernel)
281
+ const resolver = new LaunchRequirements(kernel)
282
+ kernel.api = api
283
+ kernel.launchRequirements = resolver
284
+ kernel.ensureLaunchRequirements = (launchPath, options = {}) => resolver.ensureForLaunchPath(launchPath, options)
285
+ kernel.beginLaunchOperation = (launchPath, meta = {}) => resolver.beginLaunchOperation(launchPath, meta)
286
+ kernel.endLaunchOperation = (token) => resolver.endLaunchOperation(token)
287
+ kernel.hasLaunchRequirementConfig = async (launchPath) => {
288
+ const appId = kernel.getAppIdForLaunchPath(launchPath)
289
+ if (!appId) return false
290
+ const env = await Environment.get(path.resolve(apiRoot, appId), kernel)
291
+ return !!(env.PINOKIO_SCRIPT_AUTOLAUNCH && env.PINOKIO_SCRIPT_REQUIRES)
292
+ }
293
+ api.userdir = apiRoot
294
+ api.running = {}
295
+ api.listen("test", (packet) => events.push(packet))
296
+ api.listApps = async () => ([
297
+ { id: "helper", title: "Helper", icon: "/icon/helper.png" },
298
+ { id: "target", title: "Target", icon: "/icon/target.png" }
299
+ ])
300
+ api.resolvePath = (base, uri) => {
301
+ if (typeof uri === "string" && uri.startsWith("~/")) {
302
+ return path.resolve(homedir, uri.slice(2))
303
+ }
304
+ return path.isAbsolute(uri) ? path.resolve(uri) : path.resolve(base, uri)
305
+ }
306
+ api.resolveScript = async (scriptPath) => ({
307
+ cwd: path.dirname(scriptPath),
308
+ script: { run: [{ method: "noop" }] }
309
+ })
310
+ api.isActionCandidate = () => true
311
+ api.resolveActionSteps = async () => [{ method: "noop" }]
312
+ api.setRunning = (request) => {
313
+ api.running[request.path] = true
314
+ }
315
+ api.queue = (request) => {
316
+ queued.push(path.relative(apiRoot, request.path).split(path.sep).join("/"))
317
+ ready.add(path.resolve(request.path))
318
+ delete api.running[request.path]
319
+ }
320
+
321
+ try {
322
+ await fn({
323
+ api,
324
+ events,
325
+ queued,
326
+ resolver,
327
+ launchPath: (appId, script = "start.js") => path.resolve(apiRoot, appId, script)
328
+ })
329
+ } finally {
330
+ await fs.rm(root, { recursive: true, force: true })
331
+ }
332
+ }
333
+
334
+ async function withApiStopFixture(fn) {
335
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "pinokio-api-stop-"))
336
+ const homedir = path.resolve(root, "home")
337
+ const apiRoot = path.resolve(homedir, "api")
338
+ const appRoot = path.resolve(apiRoot, "target")
339
+ const launchPath = path.resolve(appRoot, "start.js")
340
+ await fs.mkdir(appRoot, { recursive: true })
341
+ await fs.writeFile(launchPath, "module.exports = { run: [] }\n", "utf8")
342
+
343
+ const killedGroups = []
344
+ const stopped = []
345
+ const packets = []
346
+ const kernel = {
347
+ homedir,
348
+ memory: { local: {} },
349
+ shell: {
350
+ kill: ({ group }) => {
351
+ killedGroups.push(group)
352
+ }
353
+ },
354
+ path: (name, ...chunks) => path.resolve(homedir, name, ...chunks),
355
+ resumeprocess: () => {},
356
+ stopCloudflare: async () => {},
357
+ markAppLaunchStopped: (scriptPath, options = {}) => {
358
+ stopped.push({ scriptPath, options })
359
+ }
360
+ }
361
+ const api = new Api(kernel)
362
+ kernel.api = api
363
+ api.userdir = apiRoot
364
+ api.resolveScript = async () => ({
365
+ cwd: appRoot,
366
+ script: {}
367
+ })
368
+ api.listen("test", (packet) => packets.push(packet))
369
+
370
+ try {
371
+ await fn({ api, launchPath, killedGroups, stopped, packets })
372
+ } finally {
373
+ await fs.rm(root, { recursive: true, force: true })
374
+ }
375
+ }
376
+
377
+ test("launch requirements runtime state model has no failed timeout or persisted stopped states", async () => {
378
+ const launchRequirements = await fs.readFile(path.resolve(__dirname, "..", "kernel", "launch_requirements.js"), "utf8")
379
+ const autolaunch = await fs.readFile(path.resolve(__dirname, "..", "kernel", "autolaunch.js"), "utf8")
380
+ const statusClient = await fs.readFile(path.resolve(__dirname, "..", "server", "views", "partials", "launch_requirements_status_client.ejs"), "utf8")
381
+
382
+ assert.doesNotMatch(launchRequirements, /activePreparationStates\(\)[\s\S]*"failed"/)
383
+ assert.doesNotMatch(launchRequirements, /activePreparationStates\(\)[\s\S]*"timeout"/)
384
+ assert.doesNotMatch(launchRequirements, /setRequirementRow\([^)]*"timeout"/)
385
+ assert.doesNotMatch(launchRequirements, /setStartupRow\([^)]*"timeout"/)
386
+ assert.doesNotMatch(launchRequirements, /state:\s*"stopped"/)
387
+ assert.doesNotMatch(autolaunch, /state:\s*"failed"/)
388
+ assert.doesNotMatch(autolaunch, /\["blocked",\s*"failed",\s*"timeout"\]/)
389
+ assert.doesNotMatch(statusClient, /row\.state === "timeout"/)
390
+ })
391
+
392
+ test("launch requirements have no automatic readiness timeout path", async () => {
393
+ const launchRequirements = await fs.readFile(path.resolve(__dirname, "..", "kernel", "launch_requirements.js"), "utf8")
394
+
395
+ assert.doesNotMatch(launchRequirements, /timeoutMs/)
396
+ assert.doesNotMatch(launchRequirements, /Timed out waiting/)
397
+ assert.doesNotMatch(launchRequirements, /Date\.now\(\) - startedAt < context\.timeoutMs/)
398
+ })
399
+
400
+ test("launch requirements use no second launch script environment key", async () => {
401
+ const forbiddenKey = "PINOKIO" + "_SCRIPT" + "_LAUNCH"
402
+ const forbiddenConstant = "SCRIPT" + "_LAUNCH" + "_KEY"
403
+ const forbiddenMigration = "migrate" + "LegacyLaunchScriptEnv"
404
+ const files = [
405
+ "kernel/environment.js",
406
+ "kernel/launch_requirements.js",
407
+ "kernel/autolaunch.js",
408
+ "server/autolaunch.js",
409
+ "test/launch-requirements.test.js",
410
+ "test/server-autolaunch.test.js",
411
+ "test/launch-settings-ui.test.js"
412
+ ]
413
+ for (const file of files) {
414
+ const text = await fs.readFile(path.resolve(__dirname, "..", file), "utf8")
415
+ assert.equal(text.includes(forbiddenKey), false, `${file} must not mention ${forbiddenKey}`)
416
+ assert.equal(text.includes(forbiddenConstant), false, `${file} must not mention ${forbiddenConstant}`)
417
+ assert.equal(text.includes(forbiddenMigration), false, `${file} must not migrate launch script state`)
418
+ }
419
+ })
420
+
421
+ test("launch requirements resolve recursive app fixtures in ancestor-first order", async () => {
422
+ await withFixtureApps({
423
+ "app-a": {},
424
+ "app-b": { deps: ["app-a"] },
425
+ "app-c": { deps: ["app-b"] }
426
+ }, {}, async ({ resolver }) => {
427
+ const apps = await resolver.appMap()
428
+ const graph = await resolver.resolveGraph([await resolver.configFor("app-c", apps)], apps)
429
+
430
+ assert.deepEqual(graph.order, ["app-a", "app-b", "app-c"])
431
+ })
432
+ })
433
+
434
+ test("launch requirements preflight cycles before starting anything", async () => {
435
+ await withFixtureApps({
436
+ "app-a": { deps: ["app-b"] },
437
+ "app-b": { deps: ["app-a"] },
438
+ "app-c": { deps: ["app-a"] }
439
+ }, {}, async ({ resolver, launchPath, starts }) => {
440
+ const result = await resolver.ensureForLaunchPath(launchPath("app-c"), { pollMs: 5 })
441
+ assert.equal(result.action, "blocked")
442
+ assert.match(result.blocked_reason, /Requirement cycle detected/)
443
+ assert.equal(starts.length, 0)
444
+ })
445
+ })
446
+
447
+ test("launch requirements expose missing requirement script in target status", async () => {
448
+ await withFixtureApps({
449
+ "app-a": { noScript: true },
450
+ "target": { deps: ["app-a"] }
451
+ }, {}, async ({ resolver, launchPath, starts }) => {
452
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
453
+ assert.equal(result.action, "blocked")
454
+ assert.equal(result.blocked_reason, "app-a has no launch script selected")
455
+
456
+ const status = resolver.getStatus("target")
457
+ assert.equal(starts.length, 0)
458
+ assert.equal(status.state, "blocked")
459
+ assert.equal(status.blocked_reason, "app-a has no launch script selected")
460
+ assert.deepEqual(status.waiting_for, ["app-a"])
461
+ assert.equal(status.requirements.length, 1)
462
+ assert.equal(status.requirements[0].id, "app-a")
463
+ assert.equal(status.requirements[0].state, "blocked")
464
+ assert.equal(status.requirements[0].blocked_reason, "app-a has no launch script selected")
465
+ })
466
+ })
467
+
468
+ test("launch requirements clear blocked status on explicit cancel", async () => {
469
+ await withFixtureApps({
470
+ "app-a": { noScript: true },
471
+ "target": { deps: ["app-a"] }
472
+ }, {}, async ({ resolver, launchPath }) => {
473
+ const targetPath = launchPath("target")
474
+ const result = await resolver.ensureForLaunchPath(targetPath, { pollMs: 5 })
475
+ assert.equal(result.action, "blocked")
476
+ assert.equal(result.blocked_reason, "app-a has no launch script selected")
477
+
478
+ assert.equal(resolver.getStatus("target").state, "blocked")
479
+ assert.equal(resolver.cancel(targetPath, { force: true }), true)
480
+ assert.equal(resolver.getStatus("target"), null)
481
+ })
482
+ })
483
+
484
+ test("startup launch exposes blocked requirement status for app page", async () => {
485
+ await withFixtureApps({
486
+ "app-a": { noScript: true, title: "App A" },
487
+ "target": { deps: ["app-a"], title: "Target" }
488
+ }, {}, async ({ resolver, launchPath }) => {
489
+ resolver.replaceStartupHomeStatus({ running: true, apps: {} })
490
+
491
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), {
492
+ request: { startup: true },
493
+ pollMs: 5
494
+ })
495
+ assert.equal(result.action, "blocked")
496
+ assert.equal(result.blocked_reason, "App A has no launch script selected")
497
+
498
+ const status = resolver.getStatus("target")
499
+ assert.equal(status.state, "blocked")
500
+ assert.equal(status.startup, true)
501
+ assert.equal(status.blocked_reason, "App A has no launch script selected")
502
+ assert.deepEqual(status.waiting_for, ["app-a"])
503
+ assert.equal(status.requirements.length, 1)
504
+ assert.equal(status.requirements[0].id, "app-a")
505
+ assert.equal(status.requirements[0].title, "App A")
506
+ assert.equal(status.requirements[0].state, "blocked")
507
+ assert.equal(status.requirements[0].blocked_reason, "App A has no launch script selected")
508
+ })
509
+ })
510
+
511
+ test("startup launch status preserves nested requirement chain", async () => {
512
+ await withFixtureApps({
513
+ "app-a": { noScript: true, title: "App A" },
514
+ "app-b": { deps: ["app-a"], title: "App B" },
515
+ "target": { deps: ["app-b"], title: "Target" }
516
+ }, {}, async ({ resolver, launchPath }) => {
517
+ resolver.replaceStartupHomeStatus({ running: true, apps: {} })
518
+
519
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), {
520
+ request: { startup: true },
521
+ pollMs: 5
522
+ })
523
+ assert.equal(result.action, "blocked")
524
+ assert.equal(result.blocked_reason, "App A has no launch script selected")
525
+
526
+ const status = resolver.getStatus("target")
527
+ assert.equal(status.state, "blocked")
528
+ assert.equal(status.startup, true)
529
+ assert.equal(status.blocked_reason, "App A has no launch script selected")
530
+ assert.deepEqual(status.requirements.map((row) => row.id), ["app-a", "app-b"])
531
+ assert.equal(status.requirements[0].state, "blocked")
532
+ assert.equal(status.requirements[1].state, "waiting")
533
+ })
534
+ })
535
+
536
+ test("launch requirements clear status related to edited app", async () => {
537
+ await withFixtureApps({
538
+ "app-a": {},
539
+ "target": { deps: ["app-a"] }
540
+ }, {}, async ({ resolver }) => {
541
+ const startupStatus = resolver.replaceStartupHomeStatus({
542
+ running: true,
543
+ apps: {
544
+ "app-a": { id: "app-a", state: "blocked" },
545
+ target: {
546
+ id: "target",
547
+ state: "blocked",
548
+ waiting_for: ["app-a"],
549
+ requirement_order: ["app-a"]
550
+ }
551
+ }
552
+ })
553
+ resolver.status.targets.target = {
554
+ app_id: "target",
555
+ state: "blocked",
556
+ waiting_for: ["app-a"],
557
+ requirement_order: ["app-a"],
558
+ requirements: {
559
+ "app-a": { id: "app-a", state: "blocked" }
560
+ }
561
+ }
562
+
563
+ resolver.clearRelated("app-a")
564
+
565
+ assert.equal(resolver.getStatus("target"), null)
566
+ assert.equal(startupStatus.apps.target, undefined)
567
+ assert.equal(startupStatus.apps["app-a"], undefined)
568
+ })
569
+ })
570
+
571
+ test("launch requirements ignore legacy autolaunch dependency key", async () => {
572
+ await withFixtureApps({
573
+ "app-a": {},
574
+ "target": { deps: ["app-a"], legacyDeps: true }
575
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
576
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
577
+
578
+ assert.deepEqual(starts.map((start) => start.app), [])
579
+ })
580
+ })
581
+
582
+ test("generated app environment preserves unsupported custom keys without cleanup", async () => {
583
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "pinokio-launch-env-"))
584
+ try {
585
+ const homedir = path.resolve(root, "home")
586
+ await fs.mkdir(homedir, { recursive: true })
587
+ await fs.writeFile(path.resolve(homedir, "ENVIRONMENT"), [
588
+ "PINOKIO_SCRIPT_AUTOLAUNCH_ORDER=kofmy,comfyfs.git",
589
+ "PINOKIO_SCRIPT_AUTOLAUNCH_DEPENDS=kofmy",
590
+ `PINOKIO_SCRIPT${"_LAUNCH"}=start.js`,
591
+ "PINOKIO_CUSTOM_KEEP=keep",
592
+ ""
593
+ ].join("\n"), "utf8")
594
+ const content = await Environment.ENV("app", homedir, {
595
+ homedir,
596
+ exists: async (target) => {
597
+ try {
598
+ await fs.access(target)
599
+ return true
600
+ } catch (_) {
601
+ return false
602
+ }
603
+ }
604
+ })
605
+
606
+ assert.match(content, /PINOKIO_SCRIPT_AUTOLAUNCH_ORDER=kofmy,comfyfs\.git/)
607
+ assert.match(content, /PINOKIO_SCRIPT_AUTOLAUNCH_DEPENDS=kofmy/)
608
+ assert.match(content, new RegExp(`PINOKIO_SCRIPT${"_LAUNCH"}=start\\.js`))
609
+ assert.match(content, /PINOKIO_CUSTOM_KEEP=keep/)
610
+ } finally {
611
+ await fs.rm(root, { recursive: true, force: true })
612
+ }
613
+ })
614
+
615
+ test("launch requirements emit target status events", async () => {
616
+ await withFixtureApps({
617
+ "app-a": {},
618
+ "target": { deps: ["app-a"] }
619
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, events }) => {
620
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
621
+
622
+ const packets = events.filter((packet) => packet && packet.id === "kernel.launch_requirements:target")
623
+ assert.ok(packets.length > 0)
624
+ assert.ok(packets.some((packet) => packet.type === "launch.requirements" && packet.data && packet.data.status))
625
+ assert.equal(packets[packets.length - 1].type, "launch.requirements")
626
+ assert.equal(packets[packets.length - 1].data.status, null)
627
+ })
628
+ })
629
+
630
+ test("api.process does not emit terminal disconnect for requirement control results", async () => {
631
+ for (const result of [
632
+ { action: "handled", app_id: "target" },
633
+ { action: "blocked", app_id: "target", blocked_reason: "target is waiting" },
634
+ { action: "cancelled", app_id: "target" }
635
+ ]) {
636
+ await withApiProcessFixture(result, async ({ api, packets, setRunningCalls }) => {
637
+ const response = await api.process({ uri: "target/start.js" })
638
+ assert.deepEqual(response.launch_requirements, result)
639
+ assert.equal(setRunningCalls(), 0)
640
+ assert.equal(packets.some((packet) => packet && packet.type === "disconnect"), false)
641
+ assert.deepEqual(
642
+ packets.filter((packet) => packet && packet.type === "launch.requirements.control").map((packet) => packet.data),
643
+ [result]
644
+ )
645
+ })
646
+ }
647
+ })
648
+
649
+ test("api.process continues into normal script execution after requirements are ready", async () => {
650
+ await withApiProcessFixture({ action: "continue" }, async ({ api, packets, setRunningCalls }) => {
651
+ await api.process({ uri: "target/start.js" })
652
+ assert.equal(setRunningCalls(), 1)
653
+ assert.equal(packets.some((packet) => packet && packet.type === "disconnect"), false)
654
+ })
655
+ })
656
+
657
+ test("api.process launches target after preparing configured launch script requirements", async () => {
658
+ await withApiBridgeFixture(async ({ api, queued, launchPath, events }) => {
659
+ const response = await api.process({ uri: launchPath("target") })
660
+
661
+ assert.equal(response && response.launch_requirements, undefined)
662
+ assert.deepEqual(queued, ["helper/start.js", "target/start.js"])
663
+ assert.equal(events.some((packet) => packet && packet.type === "disconnect"), false)
664
+ })
665
+ })
666
+
667
+ test("api.process launches target after preparing requirements from browser-style home URI", async () => {
668
+ await withApiBridgeFixture(async ({ api, queued, events }) => {
669
+ const response = await api.process({ uri: "~/api/target/start.js" })
670
+
671
+ assert.equal(response && response.launch_requirements, undefined)
672
+ assert.deepEqual(queued, ["helper/start.js", "target/start.js"])
673
+ assert.equal(events.some((packet) => packet && packet.type === "disconnect"), false)
674
+ })
675
+ })
676
+
677
+ test("api.process launches non-default target script after preparing requirements from browser-style home URI", async () => {
678
+ await withApiBridgeFixture(async ({ api, queued, events }) => {
679
+ const response = await api.process({ uri: "~/api/target/custom-launch.js" })
680
+
681
+ assert.equal(response && response.launch_requirements, undefined)
682
+ assert.deepEqual(queued, ["helper/start.js", "target/custom-launch.js"])
683
+ assert.equal(events.some((packet) => packet && packet.type === "disconnect"), false)
684
+ }, { targetScript: "custom-launch.js" })
685
+ })
686
+
687
+ test("api.process bypasses launch requirement plumbing when no launch env exists", async () => {
688
+ await withApiProcessFixture({ action: "blocked", blocked_reason: "should not run" }, async ({ api, packets, setRunningCalls, requirementCalls }) => {
689
+ await api.process({ uri: "target/start.js" })
690
+ assert.equal(requirementCalls(), 0)
691
+ assert.equal(setRunningCalls(), 1)
692
+ assert.equal(packets.some((packet) => packet && packet.type === "disconnect"), false)
693
+ }, { hasRequirementConfig: false })
694
+ })
695
+
696
+ test("api.process bypasses launch requirement plumbing when launch script exists without requirements", async () => {
697
+ await withApiProcessFixture({ action: "blocked", blocked_reason: "should not run" }, async ({ api, packets, setRunningCalls, requirementCalls }) => {
698
+ await api.process({ uri: "target/start.js" })
699
+ assert.equal(requirementCalls(), 0)
700
+ assert.equal(setRunningCalls(), 1)
701
+ assert.equal(packets.some((packet) => packet && packet.type === "disconnect"), false)
702
+ }, { hasRequirementConfig: false })
703
+ })
704
+
705
+ test("kernel launch requirement config gate requires both configured launch script and requirements", async () => {
706
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "pinokio-launch-config-gate-"))
707
+ try {
708
+ const homedir = path.resolve(root, "home")
709
+ const apiRoot = path.resolve(homedir, "api")
710
+ const appRoot = path.resolve(apiRoot, "target")
711
+ const launchPath = path.resolve(appRoot, "start.js")
712
+ await fs.mkdir(appRoot, { recursive: true })
713
+ await fs.writeFile(launchPath, "module.exports = { run: [] }\n", "utf8")
714
+
715
+ const fakeKernel = {
716
+ exists: async (filepath) => {
717
+ try {
718
+ await fs.stat(filepath)
719
+ return true
720
+ } catch (_) {
721
+ return false
722
+ }
723
+ },
724
+ path: (name, ...chunks) => path.resolve(homedir, name, ...chunks),
725
+ getAppIdForLaunchPath: (value) => {
726
+ const relative = path.relative(apiRoot, path.resolve(value))
727
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return ""
728
+ return relative.split(path.sep)[0] || ""
729
+ }
730
+ }
731
+
732
+ await fs.writeFile(path.resolve(appRoot, "ENVIRONMENT"), [
733
+ "PINOKIO_SCRIPT_AUTOLAUNCH=start.js",
734
+ "PINOKIO_SCRIPT_REQUIRES=",
735
+ ""
736
+ ].join("\n"), "utf8")
737
+ assert.equal(await Kernel.prototype.hasLaunchRequirementConfig.call(fakeKernel, launchPath), false)
738
+
739
+ await fs.writeFile(path.resolve(appRoot, "ENVIRONMENT"), [
740
+ "PINOKIO_SCRIPT_AUTOLAUNCH=",
741
+ "PINOKIO_SCRIPT_REQUIRES=helper",
742
+ ""
743
+ ].join("\n"), "utf8")
744
+ assert.equal(await Kernel.prototype.hasLaunchRequirementConfig.call(fakeKernel, launchPath), false)
745
+
746
+ await fs.writeFile(path.resolve(appRoot, "ENVIRONMENT"), [
747
+ "PINOKIO_SCRIPT_AUTOLAUNCH=start.js",
748
+ "PINOKIO_SCRIPT_REQUIRES=helper",
749
+ ""
750
+ ].join("\n"), "utf8")
751
+ assert.equal(await Kernel.prototype.hasLaunchRequirementConfig.call(fakeKernel, launchPath), true)
752
+ } finally {
753
+ await fs.rm(root, { recursive: true, force: true })
754
+ }
755
+ })
756
+
757
+ test("launch requirement lifecycle hooks are inert for no-env app launches", async () => {
758
+ await withFixtureApps({
759
+ target: { noLaunchConfig: true }
760
+ }, {}, async ({ resolver, events, launchPath }) => {
761
+ const targetPath = launchPath("target")
762
+
763
+ resolver.markStarted(targetPath)
764
+ resolver.markProgress(targetPath, { step_current: 1, step_total: 2 })
765
+ resolver.markDone(targetPath)
766
+ resolver.cancel(targetPath)
767
+ resolver.clearRelated("target")
768
+
769
+ assert.equal(resolver.getStatus("target"), null)
770
+ assert.equal(resolver.startupHomeStatus().apps.target, undefined)
771
+ assert.deepEqual(events.filter((packet) => packet && packet.type === "launch.requirements"), [])
772
+ })
773
+ })
774
+
775
+ test("kernel lifecycle hooks do not call launch requirement hooks without active requirement runtime", () => {
776
+ const calls = []
777
+ const launchPath = path.resolve("/tmp/pinokio/api/target/custom.js")
778
+ const fakeKernel = {
779
+ readyState: {
780
+ markStarted: () => ({ state: "running" }),
781
+ markProgress: () => ({ step_current: 1, step_total: 2 }),
782
+ markReady: () => ({ state: "ready" }),
783
+ markStopped: () => ({ state: "stopped" }),
784
+ getAppIdForLaunchPath: () => "target"
785
+ },
786
+ launchRequirements: {
787
+ hasRuntimeForLaunchPath: () => false,
788
+ markStartupStarted: () => calls.push("markStartupStarted"),
789
+ markStarted: () => calls.push("markStarted"),
790
+ markProgress: () => calls.push("markProgress"),
791
+ markStartupReady: () => calls.push("markStartupReady"),
792
+ markDone: () => calls.push("markDone"),
793
+ markStartupStopped: () => calls.push("markStartupStopped"),
794
+ cancel: () => {
795
+ calls.push("cancel")
796
+ return false
797
+ },
798
+ clearRelated: () => calls.push("clearRelated")
799
+ },
800
+ hasLaunchRequirementRuntime: Kernel.prototype.hasLaunchRequirementRuntime
801
+ }
802
+
803
+ Kernel.prototype.markAppLaunchStarted.call(fakeKernel, launchPath)
804
+ Kernel.prototype.markAppLaunchProgress.call(fakeKernel, launchPath, 1, 2)
805
+ Kernel.prototype.markAppLaunchReady.call(fakeKernel, launchPath)
806
+ Kernel.prototype.markAppLaunchStopped.call(fakeKernel, launchPath)
807
+
808
+ assert.deepEqual(calls, [])
809
+ })
810
+
811
+ test("launch requirements do not run for non-configured explicit app scripts", async () => {
812
+ await withFixtureApps({
813
+ "app-a": {},
814
+ "target": { deps: ["app-a"] }
815
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
816
+ await resolver.ensureForLaunchPath(launchPath("target", "install.js"), { pollMs: 5 })
817
+
818
+ assert.deepEqual(starts.map((start) => start.app), [])
819
+ })
820
+ })
821
+
822
+ test("launch requirements run when requested script is configured launch script regardless of filename", async () => {
823
+ await withFixtureApps({
824
+ "app-a": {},
825
+ "target": { deps: ["app-a"], script: "fuckyou-chatgpt.js" }
826
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
827
+ await resolver.ensureForLaunchPath(launchPath("target", "fuckyou-chatgpt.js"), { pollMs: 5 })
828
+
829
+ assert.deepEqual(starts.map((start) => start.app), ["app-a"])
830
+ })
831
+ })
832
+
833
+ test("launch requirements do not run when no launch script and no requirements are configured", async () => {
834
+ await withFixtureApps({
835
+ "target": { noLaunchConfig: true }
836
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
837
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
838
+
839
+ assert.deepEqual(starts.map((start) => start.app), [])
840
+ })
841
+ })
842
+
843
+ test("launch requirements do not block concrete scripts when stale requirements have no owning launch script", async () => {
844
+ await withFixtureApps({
845
+ "app-a": {},
846
+ "target": { deps: ["app-a"], noLaunchConfig: true }
847
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
848
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
849
+
850
+ assert.deepEqual(starts.map((start) => start.app), [])
851
+ assert.equal(resolver.getStatus("target"), null)
852
+ })
853
+ })
854
+
855
+ test("launch requirements start independent requirements in parallel", async () => {
856
+ await withFixtureApps({
857
+ "app-a": {},
858
+ "app-b": {},
859
+ "app-c": {},
860
+ "target": { deps: ["app-a", "app-b", "app-c"] }
861
+ }, { readyDelayMs: 40 }, async ({ resolver, launchPath, starts }) => {
862
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
863
+
864
+ assert.deepEqual(starts.map((start) => start.app).sort(), ["app-a", "app-b", "app-c"])
865
+ const spread = Math.max(...starts.map((start) => start.at)) - Math.min(...starts.map((start) => start.at))
866
+ assert.ok(spread < 30, `expected independent requirements to start together, got ${spread}ms spread`)
867
+ })
868
+ })
869
+
870
+ test("launch requirements include waiting reason on nested requirement rows", async () => {
871
+ await withFixtureApps({
872
+ "app-a": {},
873
+ "app-b": { deps: ["app-a"] },
874
+ "target": { deps: ["app-b"] }
875
+ }, { readyDelayMs: 30 }, async ({ resolver, launchPath, events }) => {
876
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
877
+
878
+ const nestedWaitingStatus = events
879
+ .map((event) => event && event.data ? event.data.status : null)
880
+ .find((status) => {
881
+ const row = status && Array.isArray(status.requirements)
882
+ ? status.requirements.find((requirement) => requirement.id === "app-b")
883
+ : null
884
+ return row && row.state === "waiting" && Array.isArray(row.waiting_for) && row.waiting_for.includes("app-a")
885
+ })
886
+
887
+ assert.ok(nestedWaitingStatus, "expected app-b waiting row to explain it is waiting for app-a")
888
+ })
889
+ })
890
+
891
+ test("launch requirements do not restart an already ready requirement", async () => {
892
+ await withFixtureApps({
893
+ "app-a": {},
894
+ "target": { deps: ["app-a"] }
895
+ }, {}, async ({ resolver, launchPath, markReady, starts }) => {
896
+ markReady("app-a")
897
+
898
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
899
+
900
+ assert.equal(starts.length, 0)
901
+ })
902
+ })
903
+
904
+ test("launch requirements wait for an already running requirement instead of duplicating it", async () => {
905
+ await withFixtureApps({
906
+ "app-a": {},
907
+ "target": { deps: ["app-a"] }
908
+ }, { readyAfterStart: false }, async ({ resolver, launchPath, running, markReady, starts }) => {
909
+ running[launchPath("app-a")] = true
910
+ setTimeout(() => markReady("app-a"), 30)
911
+
912
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
913
+
914
+ assert.equal(starts.length, 0)
915
+ })
916
+ })
917
+
918
+ test("normal completion of a required app does not cancel the waiting target launch", async () => {
919
+ await withFixtureApps({
920
+ "app-a": {},
921
+ "target": { deps: ["app-a"] }
922
+ }, { readyDelayMs: 20, simulateKernelLifecycle: true }, async ({ resolver, launchPath, starts }) => {
923
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
924
+
925
+ assert.equal(result.action, "continue")
926
+ assert.deepEqual(starts.map((start) => start.app), ["app-a"])
927
+ })
928
+ })
929
+
930
+ test("display-only startup row does not prevent manual target launch", async () => {
931
+ await withFixtureApps({
932
+ "app-a": {},
933
+ "target": { deps: ["app-a"] }
934
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
935
+ resolver.replaceStartupHomeStatus({
936
+ running: true,
937
+ apps: {
938
+ target: {
939
+ id: "target",
940
+ state: "waiting",
941
+ launch_path: launchPath("target"),
942
+ waiting_for: ["app-a"]
943
+ }
944
+ }
945
+ })
946
+
947
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
948
+ assert.equal(result.action, "continue")
949
+
950
+ assert.deepEqual(starts.map((start) => start.app), ["app-a"])
951
+ })
952
+ })
953
+
954
+ test("active launch ownership prevents duplicate manual target launch", async () => {
955
+ await withFixtureApps({
956
+ "app-a": {},
957
+ "target": { deps: ["app-a"] }
958
+ }, {}, async ({ resolver, launchPath, starts }) => {
959
+ const token = resolver.beginLaunchOperation(launchPath("target"))
960
+ try {
961
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
962
+ assert.equal(result.action, "handled")
963
+ assert.equal(result.app_id, "target")
964
+ assert.equal(starts.length, 0)
965
+ } finally {
966
+ resolver.endLaunchOperation(token)
967
+ }
968
+ })
969
+ })
970
+
971
+ test("display-only startup row for offline required app still starts required app", async () => {
972
+ await withFixtureApps({
973
+ "required-app": {},
974
+ "target": { deps: ["required-app"] }
975
+ }, { readyDelayMs: 20 }, async ({ resolver, launchPath, starts }) => {
976
+ resolver.replaceStartupHomeStatus({
977
+ running: true,
978
+ apps: {
979
+ "required-app": {
980
+ id: "required-app",
981
+ state: "pending",
982
+ launch_path: launchPath("required-app"),
983
+ script: "start.js",
984
+ startup_root: true,
985
+ waiting_for: []
986
+ }
987
+ }
988
+ })
989
+
990
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
991
+
992
+ assert.equal(result.action, "continue")
993
+ assert.deepEqual(starts.map((start) => start.app), ["required-app"])
994
+ })
995
+ })
996
+
997
+ test("stale script progress is hidden when there is no active or running launch", async () => {
998
+ await withFixtureApps({
999
+ "required-app": {}
1000
+ }, {}, async ({ resolver, kernel, launchPath }) => {
1001
+ kernel.getScriptProgress = () => ({ step_current: 2, step_total: 2 })
1002
+ const apps = await resolver.appMap()
1003
+ const config = await resolver.configFor("required-app", apps)
1004
+
1005
+ const row = resolver.makeRow(config, "waiting")
1006
+ assert.equal(row.step_current, undefined)
1007
+ assert.equal(row.step_total, undefined)
1008
+
1009
+ const token = resolver.beginLaunchOperation(launchPath("required-app"))
1010
+ try {
1011
+ const activeRow = resolver.makeRow(config, "starting")
1012
+ assert.equal(activeRow.step_current, 2)
1013
+ assert.equal(activeRow.step_total, 2)
1014
+ } finally {
1015
+ resolver.endLaunchOperation(token)
1016
+ }
1017
+ })
1018
+ })
1019
+
1020
+ test("launch requirement status does not surface stale progress without an active launch", async () => {
1021
+ await withFixtureApps({
1022
+ "required-app": {},
1023
+ "target": { deps: ["required-app"] }
1024
+ }, {}, async ({ resolver, kernel, launchPath }) => {
1025
+ kernel.getScriptProgress = () => ({ step_current: 2, step_total: 2 })
1026
+ const apps = await resolver.appMap()
1027
+ const targetConfig = await resolver.configFor("target", apps)
1028
+ const requiredConfig = await resolver.configFor("required-app", apps)
1029
+
1030
+ resolver.setTargetStatus(targetConfig, "waiting", {
1031
+ requirements: {},
1032
+ requirement_order: ["required-app"],
1033
+ waiting_for: ["required-app"]
1034
+ })
1035
+ resolver.setRequirementRow("target", requiredConfig, "starting")
1036
+
1037
+ const staleStatus = resolver.getStatus("target")
1038
+ assert.equal(staleStatus.requirements[0].step_current, undefined)
1039
+ assert.equal(staleStatus.requirements[0].step_total, undefined)
1040
+
1041
+ const token = resolver.beginLaunchOperation(launchPath("required-app"))
1042
+ try {
1043
+ const activeStatus = resolver.getStatus("target")
1044
+ assert.equal(activeStatus.requirements[0].step_current, 2)
1045
+ assert.equal(activeStatus.requirements[0].step_total, 2)
1046
+ } finally {
1047
+ resolver.endLaunchOperation(token)
1048
+ }
1049
+ })
1050
+ })
1051
+
1052
+ test("startup rows clear stale progress before a fresh launch reports progress", async () => {
1053
+ await withFixtureApps({
1054
+ "required-app": {}
1055
+ }, {}, async ({ resolver, kernel, launchPath }) => {
1056
+ kernel.getScriptProgress = () => ({ step_current: 2, step_total: 2 })
1057
+ const apps = await resolver.appMap()
1058
+ const config = await resolver.configFor("required-app", apps)
1059
+ const startupStatus = resolver.replaceStartupHomeStatus({
1060
+ running: true,
1061
+ apps: {
1062
+ "required-app": {
1063
+ id: "required-app",
1064
+ state: "ready",
1065
+ launch_path: launchPath("required-app"),
1066
+ script: "start.js",
1067
+ step_current: 2,
1068
+ step_total: 2
1069
+ }
1070
+ }
1071
+ })
1072
+
1073
+ resolver.markStartupStarted("required-app")
1074
+ assert.equal(startupStatus.apps["required-app"].step_current, undefined)
1075
+ assert.equal(startupStatus.apps["required-app"].step_total, undefined)
1076
+
1077
+ resolver.setStartupRow(startupStatus, config, "starting")
1078
+ assert.equal(startupStatus.apps["required-app"].step_current, undefined)
1079
+ assert.equal(startupStatus.apps["required-app"].step_total, undefined)
1080
+
1081
+ const token = resolver.beginLaunchOperation(launchPath("required-app"))
1082
+ try {
1083
+ resolver.setStartupRow(startupStatus, config, "starting")
1084
+ assert.equal(startupStatus.apps["required-app"].step_current, 2)
1085
+ assert.equal(startupStatus.apps["required-app"].step_total, 2)
1086
+ } finally {
1087
+ resolver.endLaunchOperation(token)
1088
+ }
1089
+ })
1090
+ })
1091
+
1092
+ test("explicitly stopping a required app clears dependent launch requirement state", async () => {
1093
+ await withFixtureApps({
1094
+ "required-app": {},
1095
+ "target": { deps: ["required-app"] }
1096
+ }, {}, async ({ resolver, kernel, launchPath }) => {
1097
+ const apps = await resolver.appMap()
1098
+ const targetConfig = await resolver.configFor("target", apps)
1099
+ const requiredConfig = await resolver.configFor("required-app", apps)
1100
+
1101
+ resolver.setTargetStatus(targetConfig, "waiting", {
1102
+ requirements: {},
1103
+ requirement_order: ["required-app"],
1104
+ waiting_for: ["required-app"]
1105
+ })
1106
+ resolver.setRequirementRow("target", requiredConfig, "starting")
1107
+ resolver.replaceStartupHomeStatus({
1108
+ running: true,
1109
+ apps: {
1110
+ target: {
1111
+ id: "target",
1112
+ state: "waiting",
1113
+ waiting_for: ["required-app"],
1114
+ dependencies: ["required-app"]
1115
+ },
1116
+ "required-app": {
1117
+ id: "required-app",
1118
+ state: "starting",
1119
+ waiting_for: []
1120
+ }
1121
+ }
1122
+ })
1123
+
1124
+ assert.ok(resolver.getStatus("target"))
1125
+
1126
+ kernel.markAppLaunchStopped(launchPath("required-app"))
1127
+
1128
+ assert.equal(resolver.getStatus("target"), null)
1129
+ assert.equal(resolver.cancelled.has("target"), true)
1130
+ assert.equal(resolver.startupHomeStatus().apps.target, undefined)
1131
+ assert.equal(resolver.startupHomeStatus().apps["required-app"], undefined)
1132
+ })
1133
+ })
1134
+
1135
+ test("api.stop stops the canonical script path when the visible run id is decorated", async () => {
1136
+ await withApiStopFixture(async ({ api, launchPath, killedGroups, stopped }) => {
1137
+ const decoratedId = `${launchPath}?cwd=undefined`
1138
+ api.running[launchPath] = true
1139
+ api.running[decoratedId] = true
1140
+ api.kernel.memory.local[launchPath] = { live: true }
1141
+ api.kernel.memory.local[decoratedId] = { live: true }
1142
+
1143
+ await api.stop({ params: { id: decoratedId } })
1144
+
1145
+ assert.equal(api.running[launchPath], undefined)
1146
+ assert.equal(api.running[decoratedId], undefined)
1147
+ assert.equal(api.kernel.memory.local[launchPath], undefined)
1148
+ assert.equal(api.kernel.memory.local[decoratedId], undefined)
1149
+ assert.ok(killedGroups.includes(launchPath), "canonical script group must be killed")
1150
+ assert.ok(killedGroups.includes(decoratedId), "visible run id group must remain stopped")
1151
+ assert.equal(stopped.length, 1)
1152
+ assert.equal(stopped[0].scriptPath, launchPath)
1153
+ })
1154
+ })
1155
+
1156
+ test("startup launch request does not block on its own startup status row", async () => {
1157
+ await withFixtureApps({
1158
+ target: {}
1159
+ }, {}, async ({ resolver, launchPath, starts }) => {
1160
+ resolver.replaceStartupHomeStatus({
1161
+ running: true,
1162
+ apps: {
1163
+ target: {
1164
+ id: "target",
1165
+ state: "pending",
1166
+ launch_path: launchPath("target"),
1167
+ waiting_for: []
1168
+ }
1169
+ }
1170
+ })
1171
+
1172
+ await resolver.ensureForLaunchPath(launchPath("target"), {
1173
+ request: { startup: true },
1174
+ pollMs: 5
1175
+ })
1176
+
1177
+ assert.equal(starts.length, 0)
1178
+ })
1179
+ })
1180
+
1181
+ test("manual launch ignores display-only blocked startup row and evaluates current config", async () => {
1182
+ await withFixtureApps({
1183
+ "app-a": { noScript: true },
1184
+ "target": { deps: ["app-a"] }
1185
+ }, {}, async ({ resolver, launchPath, starts }) => {
1186
+ const startupStatus = resolver.replaceStartupHomeStatus({
1187
+ running: false,
1188
+ apps: {
1189
+ target: {
1190
+ id: "target",
1191
+ state: "blocked",
1192
+ launch_path: launchPath("target"),
1193
+ blocked_reason: "app-a has no launch script selected",
1194
+ waiting_for: ["app-a"],
1195
+ requirement_order: ["app-a"]
1196
+ },
1197
+ "app-a": {
1198
+ id: "app-a",
1199
+ state: "blocked",
1200
+ blocked_reason: "app-a has no launch script selected"
1201
+ }
1202
+ }
1203
+ })
1204
+
1205
+ const result = await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
1206
+ assert.equal(result.action, "blocked")
1207
+ assert.equal(result.blocked_reason, "app-a has no launch script selected")
1208
+
1209
+ assert.equal(starts.length, 0)
1210
+ assert.equal(resolver.getStatus("target").state, "blocked")
1211
+ assert.equal(startupStatus.apps.target.state, "blocked")
1212
+ })
1213
+ })
1214
+
1215
+ test("autolaunch scheduler starts roots through normal api.process requests", async () => {
1216
+ await withFixtureApps({
1217
+ target: { autolaunch: true, title: "Target" }
1218
+ }, {}, async ({ kernel, starts, launchPath }) => {
1219
+ const requests = []
1220
+ const originalProcess = kernel.api.process
1221
+ kernel.i = {
1222
+ api: [{ path: "target", title: "Target" }]
1223
+ }
1224
+ kernel.api.process = async (request) => {
1225
+ requests.push({ ...request })
1226
+ return originalProcess(request)
1227
+ }
1228
+
1229
+ const autolaunch = new Autolaunch(kernel)
1230
+ await autolaunch.runScheduler()
1231
+
1232
+ assert.equal(requests.length, 1)
1233
+ assert.equal(path.resolve(requests[0].uri), launchPath("target"))
1234
+ assert.equal(requests[0].startup, true)
1235
+ assert.equal(requests[0].skip_requirements, undefined)
1236
+ assert.deepEqual(starts.map((start) => start.app), ["target"])
1237
+ })
1238
+ })
1239
+
1240
+ test("autolaunch scheduler skips configured scripts when startup is disabled", async () => {
1241
+ await withFixtureApps({
1242
+ target: { startup: false, title: "Target" }
1243
+ }, {}, async ({ kernel, starts, launchPath }) => {
1244
+ const requests = []
1245
+ const originalProcess = kernel.api.process
1246
+ kernel.i = {
1247
+ api: [{ path: "target", title: "Target" }]
1248
+ }
1249
+ kernel.api.process = async (request) => {
1250
+ requests.push({ ...request })
1251
+ return originalProcess(request)
1252
+ }
1253
+
1254
+ const autolaunch = new Autolaunch(kernel)
1255
+ await autolaunch.runScheduler()
1256
+
1257
+ const env = await fs.readFile(path.resolve(kernel.api.userdir, "target", "ENVIRONMENT"), "utf8")
1258
+ assert.equal(env.includes("PINOKIO_SCRIPT_AUTOLAUNCH=start.js"), true)
1259
+ assert.equal(requests.length, 0)
1260
+ assert.deepEqual(starts.map((start) => start.app), [])
1261
+ assert.equal(kernel.autolaunch_status.apps.target, undefined)
1262
+ })
1263
+ })
1264
+
1265
+ test("autolaunch scheduler seeds startup requirements before launching roots", async () => {
1266
+ await withFixtureApps({
1267
+ "app-a": { title: "App A" },
1268
+ target: { autolaunch: true, deps: ["app-a"], title: "Target" }
1269
+ }, { readyAfterStart: false }, async ({ kernel, resolver, launchPath }) => {
1270
+ let firstStatus = null
1271
+ const originalProcess = kernel.api.process
1272
+ kernel.i = {
1273
+ api: [{ path: "target", title: "Target" }]
1274
+ }
1275
+ kernel.launchRequirements = resolver
1276
+ kernel.api.process = async (request) => {
1277
+ if (!firstStatus) {
1278
+ firstStatus = JSON.parse(JSON.stringify(kernel.autolaunch_status))
1279
+ }
1280
+ return originalProcess(request)
1281
+ }
1282
+
1283
+ const autolaunch = new Autolaunch(kernel)
1284
+ await autolaunch.runScheduler()
1285
+
1286
+ assert.ok(firstStatus)
1287
+ assert.equal(firstStatus.apps.target.startup_root, true)
1288
+ assert.equal(firstStatus.apps.target.launch_path, launchPath("target"))
1289
+ assert.deepEqual(firstStatus.apps.target.dependencies, ["app-a"])
1290
+ assert.deepEqual(firstStatus.apps.target.waiting_for, ["app-a"])
1291
+ assert.equal(firstStatus.apps["app-a"].startup_root, false)
1292
+ assert.equal(firstStatus.apps["app-a"].launch_path, launchPath("app-a"))
1293
+ assert.equal(firstStatus.apps["app-a"].state, "pending")
1294
+ })
1295
+ })
1296
+
1297
+ test("autolaunch scheduler must not migrate startup script into a second launch key", async () => {
1298
+ await withFixtureApps({
1299
+ target: { autolaunch: true, noLaunchConfig: true, title: "Target" }
1300
+ }, {}, async ({ kernel, starts, launchPath }) => {
1301
+ const requests = []
1302
+ const originalProcess = kernel.api.process
1303
+ kernel.i = {
1304
+ api: [{ path: "target", title: "Target" }]
1305
+ }
1306
+ kernel.api.process = async (request) => {
1307
+ requests.push({ ...request })
1308
+ return originalProcess(request)
1309
+ }
1310
+
1311
+ const autolaunch = new Autolaunch(kernel)
1312
+ await autolaunch.runScheduler()
1313
+
1314
+ const env = await fs.readFile(path.resolve(kernel.api.userdir, "target", "ENVIRONMENT"), "utf8")
1315
+ const forbiddenKey = "PINOKIO" + "_SCRIPT" + "_LAUNCH"
1316
+ assert.equal(env.includes(forbiddenKey), false)
1317
+ assert.equal(requests.length, 0)
1318
+ assert.deepEqual(starts.map((start) => start.app), [])
1319
+ })
1320
+ })
1321
+
1322
+ test("startup launch mirrors requirement-only apps into startup status", async () => {
1323
+ await withFixtureApps({
1324
+ "app-a": {},
1325
+ target: { deps: ["app-a"], title: "Target" }
1326
+ }, { readyAfterStart: false }, async ({ resolver, launchPath, starts }) => {
1327
+ const targetPath = launchPath("target")
1328
+ const startupStatus = resolver.replaceStartupHomeStatus({
1329
+ running: true,
1330
+ apps: {
1331
+ target: {
1332
+ id: "target",
1333
+ title: "Target",
1334
+ script: "start.js",
1335
+ launch_path: targetPath,
1336
+ state: "pending",
1337
+ startup_root: true,
1338
+ waiting_for: ["app-a"]
1339
+ }
1340
+ }
1341
+ })
1342
+ const pending = resolver.ensureForLaunchPath(targetPath, {
1343
+ request: { startup: true },
1344
+ pollMs: 10
1345
+ })
1346
+
1347
+ const startedAt = Date.now()
1348
+ while (!startupStatus.apps["app-a"] && Date.now() - startedAt < 500) {
1349
+ await delay(5)
1350
+ }
1351
+
1352
+ assert.deepEqual(starts.map((start) => start.app), ["app-a"])
1353
+ assert.equal(startupStatus.apps["app-a"].state, "starting")
1354
+ assert.equal(startupStatus.apps["app-a"].startup_root, false)
1355
+ assert.deepEqual(startupStatus.apps.target.waiting_for, ["app-a"])
1356
+
1357
+ assert.equal(resolver.cancel(targetPath), true)
1358
+ const result = await pending
1359
+ assert.equal(result.action, "cancelled")
1360
+ assert.equal(result.app_id, "target")
1361
+ })
1362
+ })
1363
+
1364
+ test("startup launch does not duplicate a startup root that is also required", async () => {
1365
+ await withFixtureApps({
1366
+ "app-a": { autolaunch: true, title: "App A" },
1367
+ target: { autolaunch: true, deps: ["app-a"], title: "Target" }
1368
+ }, { readyAfterStart: false }, async ({ kernel, resolver, apiRoot, starts, running, ready, launchPath }) => {
1369
+ kernel.i = {
1370
+ api: [
1371
+ { path: "target", title: "Target" },
1372
+ { path: "app-a", title: "App A" }
1373
+ ]
1374
+ }
1375
+ kernel.launchRequirements = resolver
1376
+ kernel.api.process = async (request) => {
1377
+ const uri = path.resolve(request.uri)
1378
+ if (!request.skip_requirements) {
1379
+ const requirementResult = await resolver.ensureForLaunchPath(uri, {
1380
+ request,
1381
+ pollMs: 5
1382
+ })
1383
+ if (requirementResult && requirementResult.action !== "continue") {
1384
+ return requirementResult
1385
+ }
1386
+ }
1387
+ starts.push({
1388
+ uri,
1389
+ app: path.relative(apiRoot, uri).split(path.sep)[0],
1390
+ at: Date.now(),
1391
+ startup: !!request.startup,
1392
+ skip_requirements: !!request.skip_requirements
1393
+ })
1394
+ running[uri] = true
1395
+ setTimeout(() => ready.add(uri), 20)
1396
+ }
1397
+
1398
+ const autolaunch = new Autolaunch(kernel)
1399
+ await autolaunch.runScheduler()
1400
+
1401
+ assert.equal(starts.filter((start) => start.app === "app-a").length, 1)
1402
+ assert.equal(starts.filter((start) => start.app === "target").length, 1)
1403
+ assert.equal(kernel.autolaunch_status.apps["app-a"].startup_root, true)
1404
+ assert.equal(kernel.autolaunch_status.apps["app-a"].launch_path, launchPath("app-a"))
1405
+ })
1406
+ })
1407
+
1408
+ test("autolaunch status is backed by launch requirements startup status", async () => {
1409
+ await withFixtureApps({
1410
+ target: { autolaunch: true, title: "Target" }
1411
+ }, {}, async ({ kernel }) => {
1412
+ const resolver = new LaunchRequirements(kernel)
1413
+ kernel.launchRequirements = resolver
1414
+
1415
+ const autolaunch = new Autolaunch(kernel)
1416
+ assert.strictEqual(autolaunch.startupStatus(), resolver.startupHomeStatus())
1417
+ assert.strictEqual(kernel.autolaunch_status, resolver.startupHomeStatus())
1418
+
1419
+ const nextStatus = {
1420
+ running: true,
1421
+ apps: {
1422
+ target: { id: "target", state: "pending" }
1423
+ }
1424
+ }
1425
+ autolaunch.setStatus(nextStatus)
1426
+
1427
+ assert.strictEqual(autolaunch.startupStatus(), resolver.startupHomeStatus())
1428
+ assert.strictEqual(kernel.autolaunch_status, resolver.startupHomeStatus())
1429
+ assert.equal(resolver.startupHomeStatus().apps.target.state, "pending")
1430
+ })
1431
+ })
1432
+
1433
+ test("launch requirements clears startup status on stop instead of persisting stopped state", async () => {
1434
+ await withFixtureApps({
1435
+ target: { title: "Target" }
1436
+ }, {}, async ({ resolver }) => {
1437
+ const startupStatus = resolver.replaceStartupHomeStatus({
1438
+ running: true,
1439
+ apps: {
1440
+ target: {
1441
+ id: "target",
1442
+ state: "pending",
1443
+ waiting_for: ["helper"]
1444
+ }
1445
+ }
1446
+ })
1447
+
1448
+ resolver.markStartupStarted("target")
1449
+ assert.equal(startupStatus.apps.target.state, "starting")
1450
+ assert.deepEqual(startupStatus.apps.target.waiting_for, [])
1451
+
1452
+ resolver.markStartupReady("target")
1453
+ assert.equal(startupStatus.apps.target.state, "ready")
1454
+ assert.ok(startupStatus.apps.target.ready_at)
1455
+
1456
+ startupStatus.apps.target.state = "starting"
1457
+ resolver.markStartupStopped("target")
1458
+ assert.equal(startupStatus.apps.target, undefined)
1459
+
1460
+ startupStatus.apps.target = {
1461
+ id: "target",
1462
+ state: "blocked",
1463
+ waiting_for: []
1464
+ }
1465
+ resolver.markStartupStopped("target")
1466
+ assert.equal(startupStatus.apps.target, undefined)
1467
+ })
1468
+ })
1469
+
1470
+ test("manual explicit script is not blocked by requirement-only status without a launch path", async () => {
1471
+ await withFixtureApps({
1472
+ "app-a": { noLaunchConfig: true }
1473
+ }, {}, async ({ resolver, launchPath, starts }) => {
1474
+ const startupStatus = resolver.replaceStartupHomeStatus({
1475
+ running: false,
1476
+ apps: {
1477
+ "app-a": {
1478
+ id: "app-a",
1479
+ state: "blocked",
1480
+ blocked_reason: "app-a has no launch script selected",
1481
+ waiting_for: []
1482
+ }
1483
+ }
1484
+ })
1485
+
1486
+ await resolver.ensureForLaunchPath(launchPath("app-a"), { pollMs: 5 })
1487
+
1488
+ assert.equal(starts.length, 0)
1489
+ assert.equal(resolver.getStatus("app-a"), null)
1490
+ assert.equal(startupStatus.apps["app-a"].state, "blocked")
1491
+ })
1492
+ })
1493
+
1494
+ test("launch requirements dedupe one required app shared by concurrent targets", async () => {
1495
+ await withFixtureApps({
1496
+ "shared": {},
1497
+ "target-a": { deps: ["shared"] },
1498
+ "target-b": { deps: ["shared"] }
1499
+ }, { startDelayMs: 25, readyDelayMs: 40 }, async ({ resolver, launchPath, starts }) => {
1500
+ await Promise.all([
1501
+ resolver.ensureForLaunchPath(launchPath("target-a"), { pollMs: 5 }),
1502
+ resolver.ensureForLaunchPath(launchPath("target-b"), { pollMs: 5 })
1503
+ ])
1504
+
1505
+ assert.deepEqual(starts.map((start) => start.app), ["shared"])
1506
+ })
1507
+ })
1508
+
1509
+ test("launch requirements clear in-flight start state after a start failure", async () => {
1510
+ await withFixtureApps({
1511
+ "shared": {},
1512
+ "target": { deps: ["shared"] }
1513
+ }, { processFailuresBeforeSuccess: 1 }, async ({ resolver, launchPath, starts }) => {
1514
+ await assert.rejects(
1515
+ resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 }),
1516
+ /process failed/
1517
+ )
1518
+ assert.equal(resolver.inflightStarts.size, 0)
1519
+
1520
+ await resolver.ensureForLaunchPath(launchPath("target"), { pollMs: 5 })
1521
+
1522
+ assert.deepEqual(starts.map((start) => start.app), ["shared"])
1523
+ })
1524
+ })
1525
+
1526
+ test("launch requirements cancel a waiting target without stopping its requirement", async () => {
1527
+ await withFixtureApps({
1528
+ "shared": {},
1529
+ "target": { deps: ["shared"] }
1530
+ }, { readyAfterStart: false }, async ({ resolver, launchPath, starts, running }) => {
1531
+ const targetPath = launchPath("target")
1532
+ const sharedPath = launchPath("shared")
1533
+ const pending = resolver.ensureForLaunchPath(targetPath, { pollMs: 10 })
1534
+
1535
+ while (!resolver.getStatus("target")) {
1536
+ await delay(5)
1537
+ }
1538
+ const startedAt = Date.now()
1539
+ while (starts.length === 0 && Date.now() - startedAt < 500) {
1540
+ await delay(5)
1541
+ }
1542
+
1543
+ assert.equal(resolver.cancel(targetPath), true)
1544
+ const result = await pending
1545
+ assert.equal(result.action, "cancelled")
1546
+ assert.equal(result.app_id, "target")
1547
+ assert.equal(resolver.getStatus("target"), null)
1548
+ assert.deepEqual(starts.map((start) => start.app), ["shared"])
1549
+ assert.equal(running[sharedPath], true)
1550
+ })
1551
+ })
1552
+
1553
+ test("launch requirements cancel nested attempt clears waiting intermediate without stopping started leaf", async () => {
1554
+ await withFixtureApps({
1555
+ "leaf": {},
1556
+ "middle": { deps: ["leaf"] },
1557
+ "target": { deps: ["middle"] }
1558
+ }, { readyAfterStart: false, simulateKernelLifecycle: true }, async ({ resolver, kernel, launchPath, starts, running, ready }) => {
1559
+ const targetPath = launchPath("target")
1560
+ const middlePath = launchPath("middle")
1561
+ const leafPath = launchPath("leaf")
1562
+ const startupStatus = resolver.replaceStartupHomeStatus({
1563
+ running: true,
1564
+ apps: {}
1565
+ })
1566
+ const pending = resolver.ensureForLaunchPath(targetPath, { pollMs: 10 })
1567
+
1568
+ assert.equal(await waitFor(() => starts.some((start) => start.app === "leaf")), true)
1569
+ assert.equal(await waitFor(() => {
1570
+ return startupStatus.apps.middle && startupStatus.apps.middle.state === "waiting"
1571
+ }), true)
1572
+
1573
+ assert.equal(startupStatus.apps.middle.waiting_for.includes("leaf"), true)
1574
+ assert.equal(running[leafPath], true)
1575
+
1576
+ assert.equal(resolver.cancel(targetPath), true)
1577
+ const result = await pending
1578
+
1579
+ assert.equal(result.action, "cancelled")
1580
+ assert.equal(result.app_id, "target")
1581
+ assert.equal(resolver.getStatus("target"), null)
1582
+ assert.equal(startupStatus.apps.target, undefined)
1583
+ assert.equal(startupStatus.apps.middle, undefined)
1584
+ assert.equal(startupStatus.apps.leaf.state, "starting")
1585
+ assert.equal(running[leafPath], true)
1586
+ assert.deepEqual(starts.map((start) => start.app), ["leaf"])
1587
+
1588
+ ready.add(leafPath)
1589
+ kernel.markAppLaunchReady(leafPath)
1590
+ await delay(30)
1591
+
1592
+ assert.equal(startupStatus.apps.middle, undefined)
1593
+ assert.equal(running[middlePath], undefined)
1594
+ assert.deepEqual(starts.map((start) => start.app), ["leaf"])
1595
+ })
1596
+ })
1597
+
1598
+ test("launch requirements cancel clears stale process-started intermediate rows that are not running or ready", async () => {
1599
+ await withFixtureApps({
1600
+ "leaf": {},
1601
+ "middle": { deps: ["leaf"] },
1602
+ "target": { deps: ["middle"] }
1603
+ }, {}, async ({ resolver, launchPath, running, ready }) => {
1604
+ const targetPath = launchPath("target")
1605
+ const middlePath = launchPath("middle")
1606
+ const leafPath = launchPath("leaf")
1607
+ resolver.setTargetStatus({
1608
+ id: "target",
1609
+ title: "Target",
1610
+ icon: "/icon/target.png",
1611
+ launchScript: "start.js",
1612
+ launchPath: targetPath,
1613
+ dependencies: ["middle"]
1614
+ }, "waiting", {
1615
+ waiting_for: ["middle"]
1616
+ })
1617
+ const startupStatus = resolver.replaceStartupHomeStatus({
1618
+ running: true,
1619
+ apps: {
1620
+ target: {
1621
+ id: "target",
1622
+ title: "Target",
1623
+ script: "start.js",
1624
+ launch_path: targetPath,
1625
+ state: "waiting",
1626
+ waiting_for: ["middle"],
1627
+ owner_app_ids: ["target"]
1628
+ },
1629
+ middle: {
1630
+ id: "middle",
1631
+ title: "Middle",
1632
+ script: "start.js",
1633
+ launch_path: middlePath,
1634
+ state: "starting",
1635
+ waiting_for: [],
1636
+ process_started: true,
1637
+ owner_app_ids: ["target"]
1638
+ },
1639
+ leaf: {
1640
+ id: "leaf",
1641
+ title: "Leaf",
1642
+ script: "start.js",
1643
+ launch_path: leafPath,
1644
+ state: "starting",
1645
+ waiting_for: [],
1646
+ process_started: true,
1647
+ owner_app_ids: ["target"]
1648
+ }
1649
+ }
1650
+ })
1651
+ running[leafPath] = true
1652
+ assert.equal(ready.has(middlePath), false)
1653
+ assert.equal(running[middlePath], undefined)
1654
+
1655
+ assert.equal(resolver.cancel(targetPath), true)
1656
+
1657
+ assert.equal(startupStatus.apps.target, undefined)
1658
+ assert.equal(startupStatus.apps.middle, undefined)
1659
+ assert.equal(startupStatus.apps.leaf.state, "starting")
1660
+ assert.equal(running[leafPath], true)
1661
+ })
1662
+ })
1663
+
1664
+ test("launch requirements force cancel clears target startup row even when active target state is gone", async () => {
1665
+ await withFixtureApps({
1666
+ "leaf": {},
1667
+ "target": { deps: ["leaf"] }
1668
+ }, {}, async ({ resolver, launchPath, running }) => {
1669
+ const targetPath = launchPath("target")
1670
+ const leafPath = launchPath("leaf")
1671
+ const startupStatus = resolver.replaceStartupHomeStatus({
1672
+ running: true,
1673
+ apps: {
1674
+ target: {
1675
+ id: "target",
1676
+ title: "Target",
1677
+ script: "start.js",
1678
+ launch_path: targetPath,
1679
+ state: "waiting",
1680
+ waiting_for: ["leaf"],
1681
+ startup_root: true
1682
+ },
1683
+ leaf: {
1684
+ id: "leaf",
1685
+ title: "Leaf",
1686
+ script: "start.js",
1687
+ launch_path: leafPath,
1688
+ state: "starting",
1689
+ waiting_for: [],
1690
+ owner_app_ids: ["target"],
1691
+ process_started: true
1692
+ }
1693
+ }
1694
+ })
1695
+ running[leafPath] = true
1696
+
1697
+ assert.equal(resolver.cancel(targetPath, { force: true }), true)
1698
+
1699
+ assert.equal(resolver.cancelled.has("target"), true)
1700
+ assert.equal(startupStatus.apps.target, undefined)
1701
+ assert.equal(startupStatus.apps.leaf.state, "starting")
1702
+ assert.equal(startupStatus.apps.leaf.owner_app_ids, undefined)
1703
+ assert.equal(running[leafPath], true)
1704
+ })
1705
+ })
1706
+
1707
+ test("launch requirements reentrant target check after cancel does not erase cancellation", async () => {
1708
+ await withFixtureApps({
1709
+ "leaf": {},
1710
+ "middle": { deps: ["leaf"] },
1711
+ "target": { deps: ["middle"] }
1712
+ }, { readyAfterStart: false, simulateKernelLifecycle: true }, async ({ resolver, kernel, launchPath, starts, running, ready }) => {
1713
+ const targetPath = launchPath("target")
1714
+ const middlePath = launchPath("middle")
1715
+ const leafPath = launchPath("leaf")
1716
+ const owner = resolver.beginLaunchOperation(targetPath, { request: { uri: targetPath } })
1717
+ const pending = resolver.ensureForLaunchPath(targetPath, { owner, pollMs: 10 })
1718
+
1719
+ assert.equal(await waitFor(() => starts.some((start) => start.app === "leaf")), true)
1720
+ assert.equal(resolver.cancel(targetPath), true)
1721
+
1722
+ const reentrantOwner = resolver.beginLaunchOperation(targetPath, { request: { uri: targetPath } })
1723
+ const reentrant = await resolver.ensureForLaunchPath(targetPath, { owner: reentrantOwner, pollMs: 10 })
1724
+ resolver.endLaunchOperation(reentrantOwner)
1725
+
1726
+ assert.equal(reentrant.action, "cancelled")
1727
+ assert.equal(reentrant.app_id, "target")
1728
+
1729
+ ready.add(leafPath)
1730
+ kernel.markAppLaunchReady(leafPath)
1731
+ await delay(30)
1732
+
1733
+ const result = await pending
1734
+ resolver.endLaunchOperation(owner)
1735
+
1736
+ assert.equal(result.action, "cancelled")
1737
+ assert.equal(result.app_id, "target")
1738
+ assert.equal(running[leafPath], true)
1739
+ assert.equal(running[middlePath], undefined)
1740
+ assert.deepEqual(starts.map((start) => start.app), ["leaf"])
1741
+ })
1742
+ })
1743
+
1744
+ test("launch requirements startup reentry after cancel does not erase cancellation", async () => {
1745
+ await withFixtureApps({
1746
+ "leaf": {},
1747
+ "middle": { deps: ["leaf"] },
1748
+ "target": { deps: ["middle"] }
1749
+ }, { readyAfterStart: false, simulateKernelLifecycle: true }, async ({ resolver, kernel, launchPath, starts, running, ready }) => {
1750
+ const targetPath = launchPath("target")
1751
+ const middlePath = launchPath("middle")
1752
+ const leafPath = launchPath("leaf")
1753
+ const owner = resolver.beginLaunchOperation(targetPath, { request: { uri: targetPath } })
1754
+ const pending = resolver.ensureForLaunchPath(targetPath, { owner, pollMs: 10 })
1755
+
1756
+ assert.equal(await waitFor(() => starts.some((start) => start.app === "leaf")), true)
1757
+ assert.equal(resolver.cancel(targetPath), true)
1758
+
1759
+ ready.add(leafPath)
1760
+ kernel.markAppLaunchReady(leafPath)
1761
+ const result = await pending
1762
+ resolver.endLaunchOperation(owner)
1763
+
1764
+ assert.equal(result.action, "cancelled")
1765
+ assert.equal(result.app_id, "target")
1766
+
1767
+ const startupOwner = resolver.beginLaunchOperation(targetPath, {
1768
+ request: { uri: targetPath, startup: true }
1769
+ })
1770
+ const startup = await resolver.ensureForLaunchPath(targetPath, {
1771
+ owner: startupOwner,
1772
+ request: { uri: targetPath, startup: true },
1773
+ pollMs: 10
1774
+ })
1775
+ resolver.endLaunchOperation(startupOwner)
1776
+
1777
+ assert.equal(startup.action, "cancelled")
1778
+ assert.equal(startup.app_id, "target")
1779
+ assert.equal(running[leafPath], true)
1780
+ assert.equal(running[middlePath], undefined)
1781
+ assert.equal(running[targetPath], undefined)
1782
+ assert.deepEqual(starts.map((start) => start.app), ["leaf"])
1783
+ })
1784
+ })
1785
+
1786
+ test("launch requirements app-page default launch after cancel clears old cancellation", async () => {
1787
+ await withFixtureApps({
1788
+ "leaf": {},
1789
+ "middle": { deps: ["leaf"] },
1790
+ "target": { deps: ["middle"] }
1791
+ }, { readyDelayMs: 5, simulateKernelLifecycle: true }, async ({ resolver, launchPath, starts }) => {
1792
+ const targetPath = launchPath("target")
1793
+
1794
+ assert.equal(resolver.cancel(targetPath, { force: true }), true)
1795
+ assert.equal(resolver.cancelled.has("target"), true)
1796
+
1797
+ const result = await resolver.ensureForLaunchPath(targetPath, {
1798
+ request: { uri: targetPath },
1799
+ pollMs: 5
1800
+ })
1801
+
1802
+ assert.equal(result.action, "continue")
1803
+ assert.equal(resolver.cancelled.has("target"), false)
1804
+ assert.deepEqual(starts.map((start) => start.app), ["leaf", "middle"])
1805
+ })
1806
+ })