pinokiod 8.1.1 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const childProcess = require('node:child_process')
|
|
4
|
+
const crypto = require('node:crypto')
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const fsp = require('node:fs/promises')
|
|
7
|
+
const http = require('node:http')
|
|
8
|
+
const net = require('node:net')
|
|
9
|
+
const os = require('node:os')
|
|
10
|
+
const path = require('node:path')
|
|
11
|
+
|
|
12
|
+
const repoRoot = path.resolve(__dirname, '..')
|
|
13
|
+
const outputRoot = path.resolve(repoRoot, 'output', 'playwright', 'nested-launcher-real-browser')
|
|
14
|
+
const readyPrefix = 'PINOKIO_NESTED_BROWSER_READY '
|
|
15
|
+
const resultPrefix = 'PINOKIO_NESTED_BROWSER_RESULT '
|
|
16
|
+
const appId = 'nested-browser-acceptance'
|
|
17
|
+
const inheritanceId = 'nested-browser-inheritance'
|
|
18
|
+
const nestedChildId = 'nested-child-regression'
|
|
19
|
+
const historicalSession = 'historical-session'
|
|
20
|
+
const historicalSentinel = 'HISTORICAL_NESTED_BROWSER_SENTINEL'
|
|
21
|
+
const currentSentinel = 'CURRENT_NESTED_BROWSER_SENTINEL'
|
|
22
|
+
const adversarialViewerSentinel = 'ADVERSARIAL_ROOT_LOG_VIEWER_SENTINEL'
|
|
23
|
+
const adversarialHelpSentinel = 'ADVERSARIAL_NESTED_GET_HELP_SENTINEL'
|
|
24
|
+
const runtimeSentinel = 'RUNTIME_NESTED_BROWSER_SENTINEL'
|
|
25
|
+
const runtimeMarker = '.pinokio-nested-acceptance-runtime.json'
|
|
26
|
+
const liveControls = {
|
|
27
|
+
root: {
|
|
28
|
+
slug: 'github-com-halr9000-pinokio-hello-world',
|
|
29
|
+
title: 'Hello World Gradio',
|
|
30
|
+
repo: 'https://github.com/halr9000/pinokio-hello-world',
|
|
31
|
+
folder: 'pinokio-hello-world.git',
|
|
32
|
+
layout: 'root'
|
|
33
|
+
},
|
|
34
|
+
nested: {
|
|
35
|
+
slug: 'github-com-bilawalsidhu-gods-eye-view',
|
|
36
|
+
title: "God's Eye View",
|
|
37
|
+
repo: 'https://github.com/bilawalsidhu/gods-eye-view',
|
|
38
|
+
folder: 'gods-eye-view.git',
|
|
39
|
+
layout: 'nested'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
44
|
+
const args = {}
|
|
45
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
46
|
+
const item = argv[index]
|
|
47
|
+
if (!item.startsWith('--')) continue
|
|
48
|
+
const key = item.slice(2)
|
|
49
|
+
const next = argv[index + 1]
|
|
50
|
+
if (next && !next.startsWith('--')) {
|
|
51
|
+
args[key] = next
|
|
52
|
+
index += 1
|
|
53
|
+
} else {
|
|
54
|
+
args[key] = true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return args
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function run(command, args, options = {}) {
|
|
61
|
+
return childProcess.execFileSync(command, args, { encoding: 'utf8', ...options }).trim()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function copyDirectory(source, target) {
|
|
65
|
+
await fsp.mkdir(path.dirname(target), { recursive: true })
|
|
66
|
+
if (process.platform === 'darwin') {
|
|
67
|
+
childProcess.execFileSync('/bin/cp', ['-cR', source, target])
|
|
68
|
+
} else {
|
|
69
|
+
await fsp.cp(source, target, { recursive: true, preserveTimestamps: true, dereference: false, verbatimSymlinks: true })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeRuntimePath(value, label) {
|
|
74
|
+
const target = path.resolve(String(value || ''))
|
|
75
|
+
const forbidden = new Set([path.parse(target).root, os.homedir(), repoRoot])
|
|
76
|
+
assert.equal(forbidden.has(target), false, `${label} must not be a filesystem root, the user home, or the repository`)
|
|
77
|
+
return target
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runtimeManifest(root) {
|
|
81
|
+
const entries = []
|
|
82
|
+
async function visit(directory, relative = '') {
|
|
83
|
+
const names = await fsp.readdir(directory)
|
|
84
|
+
names.sort()
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
const absolute = path.resolve(directory, name)
|
|
87
|
+
const itemPath = relative ? `${relative}/${name}` : name
|
|
88
|
+
const stat = await fsp.lstat(absolute)
|
|
89
|
+
if (stat.isSymbolicLink()) {
|
|
90
|
+
entries.push({ path: itemPath, type: 'symlink', target: await fsp.readlink(absolute), mtime_ms: stat.mtimeMs })
|
|
91
|
+
} else if (stat.isDirectory()) {
|
|
92
|
+
entries.push({ path: itemPath, type: 'directory', mtime_ms: stat.mtimeMs })
|
|
93
|
+
await visit(absolute, itemPath)
|
|
94
|
+
} else if (stat.isFile()) {
|
|
95
|
+
const hash = crypto.createHash('sha256')
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
const stream = fs.createReadStream(absolute)
|
|
98
|
+
stream.on('data', chunk => hash.update(chunk))
|
|
99
|
+
stream.on('error', reject)
|
|
100
|
+
stream.on('end', resolve)
|
|
101
|
+
})
|
|
102
|
+
entries.push({ path: itemPath, type: 'file', size: stat.size, sha256: hash.digest('hex'), mtime_ms: stat.mtimeMs })
|
|
103
|
+
} else {
|
|
104
|
+
entries.push({ path: itemPath, type: 'other', size: stat.size, mtime_ms: stat.mtimeMs })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await visit(root)
|
|
109
|
+
return entries
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function prepareRuntime({ active, snapshot, createSnapshot }) {
|
|
113
|
+
const activeRoot = safeRuntimePath(active, 'runtime active path')
|
|
114
|
+
const snapshotRoot = safeRuntimePath(snapshot, 'runtime snapshot path')
|
|
115
|
+
assert.notEqual(activeRoot, snapshotRoot, 'runtime active and snapshot paths must differ')
|
|
116
|
+
assert.equal(activeRoot.startsWith(`${snapshotRoot}${path.sep}`) || snapshotRoot.startsWith(`${activeRoot}${path.sep}`), false, 'runtime active and snapshot paths must not contain one another')
|
|
117
|
+
const condaRelative = path.join('bin', 'miniforge', 'bin', process.platform === 'win32' ? 'conda.exe' : 'conda')
|
|
118
|
+
assert.equal(fs.existsSync(path.resolve(activeRoot, condaRelative)), true, `runtime is missing ${condaRelative}`)
|
|
119
|
+
if (!fs.existsSync(snapshotRoot)) {
|
|
120
|
+
assert.equal(createSnapshot, true, 'runtime snapshot does not exist; pass --create-runtime-snapshot once to create it')
|
|
121
|
+
await fsp.writeFile(path.resolve(activeRoot, runtimeMarker), JSON.stringify({ active: activeRoot, created_at: new Date().toISOString() }, null, 2))
|
|
122
|
+
await copyDirectory(activeRoot, snapshotRoot)
|
|
123
|
+
}
|
|
124
|
+
const marker = JSON.parse(await fsp.readFile(path.resolve(snapshotRoot, runtimeMarker), 'utf8'))
|
|
125
|
+
assert.equal(marker.active, activeRoot, 'runtime snapshot was provisioned for a different absolute active path')
|
|
126
|
+
assert.equal(fs.existsSync(path.resolve(snapshotRoot, condaRelative)), true, 'runtime snapshot is incomplete')
|
|
127
|
+
const manifest = await runtimeManifest(snapshotRoot)
|
|
128
|
+
async function restore() {
|
|
129
|
+
if (fs.existsSync(activeRoot)) {
|
|
130
|
+
const activeMarker = JSON.parse(await fsp.readFile(path.resolve(activeRoot, runtimeMarker), 'utf8'))
|
|
131
|
+
assert.equal(activeMarker.active, activeRoot, 'refusing to replace an unrecognized runtime directory')
|
|
132
|
+
await fsp.rm(activeRoot, { recursive: true, force: true })
|
|
133
|
+
}
|
|
134
|
+
await copyDirectory(snapshotRoot, activeRoot)
|
|
135
|
+
const restoredMarker = JSON.parse(await fsp.readFile(path.resolve(activeRoot, runtimeMarker), 'utf8'))
|
|
136
|
+
assert.equal(restoredMarker.active, activeRoot, 'restored runtime marker does not match its path')
|
|
137
|
+
const conda = path.resolve(activeRoot, condaRelative)
|
|
138
|
+
const version = run(conda, ['--version'])
|
|
139
|
+
assert.match(version, /^conda\s+\S+/, 'restored runtime failed its conda smoke check')
|
|
140
|
+
const compatibilityAlias = path.resolve(activeRoot, 'bin', 'miniconda')
|
|
141
|
+
if (fs.existsSync(compatibilityAlias) && (await fsp.lstat(compatibilityAlias)).isSymbolicLink()) {
|
|
142
|
+
const target = await fsp.readlink(compatibilityAlias)
|
|
143
|
+
assert.equal(path.isAbsolute(target), true, 'runtime compatibility alias must use its provisioned absolute path')
|
|
144
|
+
assert.equal(target === activeRoot || target.startsWith(`${activeRoot}${path.sep}`), true, 'runtime compatibility alias escapes the active runtime')
|
|
145
|
+
}
|
|
146
|
+
const condaHeader = (await fsp.readFile(conda)).subarray(0, 512).toString('utf8')
|
|
147
|
+
if (condaHeader.startsWith('#!')) assert.equal(condaHeader.split(/\r?\n/, 1)[0].includes(activeRoot), true, 'conda shebang points outside the active runtime')
|
|
148
|
+
const bin = path.resolve(activeRoot, 'bin')
|
|
149
|
+
return {
|
|
150
|
+
root: activeRoot,
|
|
151
|
+
bin,
|
|
152
|
+
conda: version,
|
|
153
|
+
node: run(path.resolve(bin, 'miniforge', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'), ['--version']),
|
|
154
|
+
git: run(path.resolve(bin, 'miniforge', 'bin', process.platform === 'win32' ? 'git.exe' : 'git'), ['--version']),
|
|
155
|
+
uv: run(path.resolve(bin, 'miniforge', 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv'), ['--version'])
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function verifySnapshot() {
|
|
159
|
+
assert.deepEqual(await runtimeManifest(snapshotRoot), manifest, 'the pristine runtime snapshot was modified')
|
|
160
|
+
}
|
|
161
|
+
return { activeRoot, snapshotRoot, manifest, restore, verifySnapshot }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function loadPlaywright() {
|
|
165
|
+
try {
|
|
166
|
+
return require('playwright')
|
|
167
|
+
} catch (_) {}
|
|
168
|
+
try {
|
|
169
|
+
const locator = process.platform === 'win32' ? 'where' : 'which'
|
|
170
|
+
const bin = run(locator, ['playwright']).split(/\r?\n/)[0]
|
|
171
|
+
return require(path.join(path.dirname(path.dirname(bin)), 'playwright'))
|
|
172
|
+
} catch (_) {}
|
|
173
|
+
throw new Error('Playwright is required. Run with: npx --yes --package playwright node test/nested-launcher-real-browser.js')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function browserExecutable(playwright) {
|
|
177
|
+
const candidates = [
|
|
178
|
+
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
|
179
|
+
process.env.BRAVE_EXECUTABLE,
|
|
180
|
+
process.platform === 'darwin' ? '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' : '',
|
|
181
|
+
process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '',
|
|
182
|
+
process.platform === 'win32' && process.env.PROGRAMFILES ? path.join(process.env.PROGRAMFILES, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe') : '',
|
|
183
|
+
process.platform === 'win32' && process.env['PROGRAMFILES(X86)'] ? path.join(process.env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe') : '',
|
|
184
|
+
'/usr/bin/brave-browser',
|
|
185
|
+
'/usr/bin/google-chrome',
|
|
186
|
+
'/usr/bin/chromium',
|
|
187
|
+
playwright.chromium.executablePath()
|
|
188
|
+
].filter(Boolean)
|
|
189
|
+
return candidates.find(candidate => fs.existsSync(candidate)) || ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function freePort() {
|
|
193
|
+
return await new Promise((resolve, reject) => {
|
|
194
|
+
const server = net.createServer()
|
|
195
|
+
server.on('error', reject)
|
|
196
|
+
server.listen(0, '127.0.0.1', () => {
|
|
197
|
+
const port = server.address().port
|
|
198
|
+
server.close(() => resolve(port))
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function write(root, relative, content = '') {
|
|
204
|
+
const target = path.resolve(root, relative)
|
|
205
|
+
await fsp.mkdir(path.dirname(target), { recursive: true })
|
|
206
|
+
await fsp.writeFile(target, content)
|
|
207
|
+
return target
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function script(runSteps, daemon = false) {
|
|
211
|
+
return `module.exports = ${JSON.stringify({ daemon, run: runSteps }, null, 2)}\n`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function launcherModule(layout) {
|
|
215
|
+
const prefix = layout === 'nested' ? 'pinokio/' : ''
|
|
216
|
+
const runCommand = `node -e "require('fs').writeFileSync('run-cwd.txt', process.cwd())"`
|
|
217
|
+
return `module.exports = {
|
|
218
|
+
version: "1.0.0",
|
|
219
|
+
title: "Nested Browser Acceptance",
|
|
220
|
+
description: "Controlled nested-launcher browser fixture.",
|
|
221
|
+
icon: "icon.png",
|
|
222
|
+
pre: [
|
|
223
|
+
{ title: "Local preflight", icon: "pre.png", href: "setup.js" },
|
|
224
|
+
{ title: "External preflight", href: "https://example.com/preflight" }
|
|
225
|
+
],
|
|
226
|
+
menu: [
|
|
227
|
+
{ default: true, text: "Start relative", href: "start.js" },
|
|
228
|
+
{ text: "Launcher child", href: "child/tool.js" },
|
|
229
|
+
{ text: "Absolute launcher", href: "/api/${appId}/${prefix}absolute.js" },
|
|
230
|
+
{ text: "Absolute workspace", href: "/api/${appId}/workspace.js" },
|
|
231
|
+
{ text: "Run string", run: ${JSON.stringify(runCommand)} },
|
|
232
|
+
{ text: "Stop relative", action: { method: "stop", uri: "start.js" } },
|
|
233
|
+
{ text: "State marker", when: "start.js", on: "STATE_ON", off: "STATE_OFF" }
|
|
234
|
+
],
|
|
235
|
+
shortcuts: [
|
|
236
|
+
{ text: "Shortcut stop", action: { method: "stop", uri: "start.js" } }
|
|
237
|
+
]
|
|
238
|
+
}\n`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64')
|
|
242
|
+
|
|
243
|
+
async function writePrimaryFixture(pinokioHome, layout) {
|
|
244
|
+
const workspace = path.resolve(pinokioHome, 'api', appId)
|
|
245
|
+
const launcher = layout === 'nested' ? path.resolve(workspace, 'pinokio') : workspace
|
|
246
|
+
await fsp.mkdir(launcher, { recursive: true })
|
|
247
|
+
await write(launcher, 'pinokio.js', launcherModule(layout))
|
|
248
|
+
await write(launcher, 'pinokio.json', JSON.stringify({
|
|
249
|
+
title: 'Nested Browser Acceptance',
|
|
250
|
+
description: 'Controlled nested-launcher browser fixture.',
|
|
251
|
+
icon: 'icon.png'
|
|
252
|
+
}, null, 2))
|
|
253
|
+
await write(launcher, 'icon.png', png)
|
|
254
|
+
await write(launcher, 'pre.png', png)
|
|
255
|
+
await write(launcher, 'install.js', script([{ method: 'local.set', params: { installed: true } }]))
|
|
256
|
+
await write(launcher, 'start.js', script([
|
|
257
|
+
{ method: 'process.wait', params: { sec: 20 } },
|
|
258
|
+
{ method: 'local.set', params: { started: true, search: 'SCRIPTSEARCHSENTINEL' } }
|
|
259
|
+
], true))
|
|
260
|
+
await write(launcher, 'env-set.js', script([{ method: 'env.set', params: { ACTION_VALUE: 'set-by-action' } }]))
|
|
261
|
+
await write(launcher, 'env-switch.js', script([{ method: 'env.switch', params: { SWITCH_VALUE: ['one', 'two'] } }]))
|
|
262
|
+
await write(launcher, 'setup.js', script([{ method: 'local.set', params: { setup: true } }]))
|
|
263
|
+
await write(launcher, 'absolute.js', script([{ method: 'local.set', params: { absolute: true } }]))
|
|
264
|
+
await write(launcher, 'child/tool.js', script([{ method: 'local.set', params: { child: true } }]))
|
|
265
|
+
await write(launcher, 'runtime-probe.js', `const fs = require('fs')
|
|
266
|
+
const result = { sentinel: ${JSON.stringify(runtimeSentinel)}, cwd: process.cwd(), relative: process.env.RELATIVE_VALUE }
|
|
267
|
+
console.log(${JSON.stringify(runtimeSentinel)}, JSON.stringify(result))
|
|
268
|
+
fs.writeFileSync('runtime-result.json', JSON.stringify(result, null, 2))
|
|
269
|
+
`)
|
|
270
|
+
await write(launcher, 'runtime.js', script([{ method: 'shell.run', params: { message: 'node runtime-probe.js' } }]))
|
|
271
|
+
if (layout === 'nested') {
|
|
272
|
+
await write(launcher, 'README.md', 'LAUNCHERREADMESENTINEL\n')
|
|
273
|
+
await write(launcher, 'CLAUDE.md', 'LAUNCHERAISENTINEL\n')
|
|
274
|
+
}
|
|
275
|
+
await write(launcher, '_ENVIRONMENT', 'TEMPLATE_SENTINEL=primary-template\n')
|
|
276
|
+
await write(launcher, 'ENVIRONMENT', [
|
|
277
|
+
'REQUIRED_VALUE=initial',
|
|
278
|
+
'ACTION_VALUE=initial',
|
|
279
|
+
'SWITCH_VALUE=one',
|
|
280
|
+
'RELATIVE_VALUE=./something',
|
|
281
|
+
'PINOKIO_SHARE_CLOUDFLARE=false',
|
|
282
|
+
'PINOKIO_SHARE_LOCAL=false',
|
|
283
|
+
'PINOKIO_SHARE_LOCAL_PORT=',
|
|
284
|
+
''
|
|
285
|
+
].join('\n'))
|
|
286
|
+
await write(workspace, 'workspace.js', script([{ method: 'local.set', params: { workspace: true } }]))
|
|
287
|
+
await write(workspace, 'README.md', layout === 'nested'
|
|
288
|
+
? 'WORKSPACEREADMESENTINEL\n'
|
|
289
|
+
: 'WORKSPACEREADMESENTINEL\nLAUNCHERREADMESENTINEL\n')
|
|
290
|
+
await write(workspace, 'CLAUDE.md', layout === 'nested'
|
|
291
|
+
? 'WORKSPACEAISENTINEL\n'
|
|
292
|
+
: 'WORKSPACEAISENTINEL\nLAUNCHERAISENTINEL\n')
|
|
293
|
+
await write(workspace, '.git/config', '[remote "origin"]\n\turl = https://github.com/pinokio/acceptance-fixture.git\n')
|
|
294
|
+
await write(workspace, '.git/info/exclude', 'existing-entry\n')
|
|
295
|
+
|
|
296
|
+
const logRoot = path.resolve(launcher, 'logs')
|
|
297
|
+
await write(logRoot, 'api/seeded.js/latest', `${currentSentinel}\n`)
|
|
298
|
+
const historicalLog = await write(logRoot, 'api/start.js/1000', `${historicalSentinel}\n`)
|
|
299
|
+
await write(logRoot, 'dev/latest', 'SEEDED_DEV_LOG\n')
|
|
300
|
+
await write(logRoot, 'shell/latest', 'SEEDED_SHELL_LOG\n')
|
|
301
|
+
await write(logRoot, 'sessions/index.json', JSON.stringify({
|
|
302
|
+
version: 1,
|
|
303
|
+
latest_session: historicalSession,
|
|
304
|
+
sessions: [{ id: historicalSession, created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', runs: ['start.js'] }]
|
|
305
|
+
}, null, 2))
|
|
306
|
+
await write(logRoot, `sessions/${historicalSession}.json`, JSON.stringify({
|
|
307
|
+
version: 1,
|
|
308
|
+
id: historicalSession,
|
|
309
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
310
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
311
|
+
runs: [{
|
|
312
|
+
script: 'start.js',
|
|
313
|
+
started_at: '2026-01-01T00:00:00.000Z',
|
|
314
|
+
ended_at: '2026-01-01T00:00:01.000Z',
|
|
315
|
+
logs: [{ path: path.relative(launcher, historicalLog).split(path.sep).join('/') }]
|
|
316
|
+
}]
|
|
317
|
+
}, null, 2))
|
|
318
|
+
return { workspace, launcher }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function writeInheritanceFixture(pinokioHome, layout) {
|
|
322
|
+
const workspace = path.resolve(pinokioHome, 'api', inheritanceId)
|
|
323
|
+
const launcher = layout === 'nested' ? path.resolve(workspace, 'pinokio') : workspace
|
|
324
|
+
await write(launcher, 'pinokio.js', `module.exports = { title: "Inheritance Fixture", menu: [{ default: true, text: "Start", href: "start.js" }] }\n`)
|
|
325
|
+
await write(launcher, 'start.js', script([{ method: 'local.set', params: { inherited: true } }]))
|
|
326
|
+
await write(launcher, '_ENVIRONMENT', 'INHERITANCE_UNIQUE_SENTINEL=yes\n')
|
|
327
|
+
await write(workspace, '.git/info/exclude', 'existing-entry\n')
|
|
328
|
+
return { workspace, launcher }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function writeNestedChildFixture(pinokioHome, layout) {
|
|
332
|
+
if (layout !== 'nested') return null
|
|
333
|
+
const workspace = path.resolve(pinokioHome, 'api', nestedChildId)
|
|
334
|
+
const launcher = path.resolve(workspace, 'pinokio')
|
|
335
|
+
await write(launcher, 'pinokio.js', `module.exports = {
|
|
336
|
+
title: "Nested Child Regression",
|
|
337
|
+
menu: [
|
|
338
|
+
{ text: "Start nested child", href: "pinokio/tool.js" },
|
|
339
|
+
{ text: "Stop nested child", action: { method: "stop", uri: "pinokio/tool.js" } }
|
|
340
|
+
]
|
|
341
|
+
}\n`)
|
|
342
|
+
await write(launcher, 'pinokio/tool.js', script([{ method: 'process.wait', params: { sec: 20 } }], true))
|
|
343
|
+
await write(launcher, 'ENVIRONMENT', '')
|
|
344
|
+
return { workspace, launcher }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function writeAdversarialFixtures(pinokioHome) {
|
|
348
|
+
const rootFolder = path.resolve(pinokioHome, 'api', 'root-with-folder')
|
|
349
|
+
await write(rootFolder, 'pinokio.js', `module.exports = { title: "Root With Folder", menu: [{ text: "Root start", href: "start.js" }] }\n`)
|
|
350
|
+
await write(rootFolder, 'start.js', script([{ method: 'process.wait', params: { sec: 20 } }], true))
|
|
351
|
+
await write(rootFolder, 'install.js', script([{ method: 'local.set', params: { installed: true } }]))
|
|
352
|
+
await write(rootFolder, 'logs/api/seeded.js/latest', `${adversarialViewerSentinel}\n`)
|
|
353
|
+
await write(rootFolder, 'pinokio/unrelated.txt', 'unrelated')
|
|
354
|
+
const nestedLog = await write(rootFolder, 'pinokio/logs/api/help.js/1000', `${adversarialHelpSentinel}\n`)
|
|
355
|
+
await write(rootFolder, 'pinokio/logs/sessions/index.json', JSON.stringify({
|
|
356
|
+
version: 1,
|
|
357
|
+
latest_session: 'adversarial-help-session',
|
|
358
|
+
sessions: [{ id: 'adversarial-help-session', created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:01.000Z', runs: ['help.js'] }]
|
|
359
|
+
}, null, 2))
|
|
360
|
+
await write(rootFolder, 'pinokio/logs/sessions/adversarial-help-session.json', JSON.stringify({
|
|
361
|
+
version: 1,
|
|
362
|
+
id: 'adversarial-help-session',
|
|
363
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
364
|
+
updated_at: '2026-01-01T00:00:01.000Z',
|
|
365
|
+
runs: [{ script: 'help.js', started_at: '2026-01-01T00:00:00.000Z', ended_at: '2026-01-01T00:00:01.000Z', logs: [{ path: path.relative(path.resolve(rootFolder, 'pinokio'), nestedLog).split(path.sep).join('/') }] }]
|
|
366
|
+
}, null, 2))
|
|
367
|
+
await write(rootFolder, '.git/config', '[remote "origin"]\n\turl = https://github.com/pinokio/root-with-folder.git\n')
|
|
368
|
+
|
|
369
|
+
const both = path.resolve(pinokioHome, 'api', 'both-launchers')
|
|
370
|
+
await write(both, 'pinokio.js', `module.exports = { title: "Root Winner", menu: [{ text: "Root winner start", href: "start.js" }] }\n`)
|
|
371
|
+
await write(both, 'start.js', script([{ method: 'process.wait', params: { sec: 20 } }], true))
|
|
372
|
+
await write(both, 'install.js', script([{ method: 'local.set', params: { installed: 'root' } }]))
|
|
373
|
+
await write(both, 'pinokio/pinokio.js', `module.exports = { title: "Nested Loser", menu: [{ text: "Nested loser start", href: "start.js" }] }\n`)
|
|
374
|
+
await write(both, 'pinokio/start.js', script([{ method: 'local.set', params: { started: 'nested' } }]))
|
|
375
|
+
await write(both, 'pinokio/install.js', script([{ method: 'local.set', params: { installed: 'nested' } }]))
|
|
376
|
+
|
|
377
|
+
const broken = path.resolve(pinokioHome, 'api', 'broken-root')
|
|
378
|
+
await write(broken, 'pinokio.js', 'throw new Error("intentional broken root")\n')
|
|
379
|
+
await write(broken, 'pinokio/pinokio.js', `module.exports = {
|
|
380
|
+
title: "Legacy Nested Fallback",
|
|
381
|
+
menu: [
|
|
382
|
+
{ text: "Fallback start", href: "start.js" },
|
|
383
|
+
{ text: "Fallback fs", href: "file.txt", fs: true },
|
|
384
|
+
{ text: "Fallback command", href: "file.txt", command: "open" },
|
|
385
|
+
{ text: "Fallback run", run: "printf fallback" },
|
|
386
|
+
{ text: "Fallback stop", action: { method: "stop", uri: "start.js" } },
|
|
387
|
+
{ text: "Fallback state", when: "start.js", on: "FALLBACK_ON", off: "FALLBACK_OFF" }
|
|
388
|
+
]
|
|
389
|
+
}\n`)
|
|
390
|
+
await write(broken, 'pinokio/start.js', script([{ method: 'process.wait', params: { sec: 20 } }], true))
|
|
391
|
+
await write(broken, 'pinokio/file.txt', 'nested fallback file\n')
|
|
392
|
+
|
|
393
|
+
await write(path.resolve(pinokioHome, 'api', 'empty-pinokio-folder'), 'pinokio/unrelated.txt', 'empty')
|
|
394
|
+
await write(path.resolve(pinokioHome, 'api', 'plain-pinokio-file'), 'pinokio', 'plain file')
|
|
395
|
+
await fsp.mkdir(path.resolve(pinokioHome, 'api', 'no-launcher'), { recursive: true })
|
|
396
|
+
await write(path.resolve(pinokioHome, 'api', 'pinokio'), 'pinokio.js', `module.exports = { title: "App Named Pinokio", menu: [] }\n`)
|
|
397
|
+
await write(path.resolve(pinokioHome, 'api', 'subfolder-project'), 'pinokio.js', `module.exports = { title: "Subfolder Parent", menu: [] }\n`)
|
|
398
|
+
await write(path.resolve(pinokioHome, 'api', 'subfolder-project'), 'child/pinokio.js', `module.exports = { title: "Subfolder Child", icon: "child.png", menu: [{ text: "Child start", href: "start.js" }] }\n`)
|
|
399
|
+
await write(path.resolve(pinokioHome, 'api', 'subfolder-project'), 'child/start.js', script([{ method: 'local.set', params: { child: true } }]))
|
|
400
|
+
await write(path.resolve(pinokioHome, 'api', 'subfolder-project'), 'child/child.png', png)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function createHome(layout, runtimeBin) {
|
|
404
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'pinokio-nested-browser-'))
|
|
405
|
+
const fakeUserHome = path.resolve(root, 'user')
|
|
406
|
+
const pinokioHome = path.resolve(root, 'pinokio-home')
|
|
407
|
+
await fsp.mkdir(path.resolve(pinokioHome, 'api'), { recursive: true })
|
|
408
|
+
await fsp.mkdir(fakeUserHome, { recursive: true })
|
|
409
|
+
if (runtimeBin) {
|
|
410
|
+
await fsp.symlink(runtimeBin, path.resolve(pinokioHome, 'bin'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
411
|
+
await write(pinokioHome, 'prototype/system/.acceptance-fixture', '')
|
|
412
|
+
await write(pinokioHome, 'prototype/PINOKIO.md', 'Acceptance fixture\n')
|
|
413
|
+
await write(pinokioHome, 'prototype/PTERM.md', 'Acceptance fixture\n')
|
|
414
|
+
}
|
|
415
|
+
if (process.platform === 'win32') {
|
|
416
|
+
await fsp.mkdir(path.resolve(fakeUserHome, 'AppData', 'Roaming'), { recursive: true })
|
|
417
|
+
await fsp.mkdir(path.resolve(fakeUserHome, 'AppData', 'Local'), { recursive: true })
|
|
418
|
+
}
|
|
419
|
+
const primary = await writePrimaryFixture(pinokioHome, layout)
|
|
420
|
+
const inheritance = await writeInheritanceFixture(pinokioHome, layout)
|
|
421
|
+
const nestedChild = await writeNestedChildFixture(pinokioHome, layout)
|
|
422
|
+
await writeAdversarialFixtures(pinokioHome)
|
|
423
|
+
return { root, fakeUserHome, pinokioHome, primary, inheritance, nestedChild }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function createLiveHome(runtimeBin) {
|
|
427
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'pinokio-nested-live-'))
|
|
428
|
+
const fakeUserHome = path.resolve(root, 'user')
|
|
429
|
+
const pinokioHome = path.resolve(root, 'pinokio-home')
|
|
430
|
+
await fsp.mkdir(path.resolve(pinokioHome, 'api'), { recursive: true })
|
|
431
|
+
await fsp.mkdir(fakeUserHome, { recursive: true })
|
|
432
|
+
if (process.platform === 'win32') {
|
|
433
|
+
await fsp.mkdir(path.resolve(fakeUserHome, 'AppData', 'Roaming'), { recursive: true })
|
|
434
|
+
await fsp.mkdir(path.resolve(fakeUserHome, 'AppData', 'Local'), { recursive: true })
|
|
435
|
+
}
|
|
436
|
+
await fsp.symlink(runtimeBin, path.resolve(pinokioHome, 'bin'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
437
|
+
await write(pinokioHome, 'prototype/system/.acceptance-fixture', '')
|
|
438
|
+
await write(pinokioHome, 'prototype/PINOKIO.md', 'Acceptance fixture\n')
|
|
439
|
+
await write(pinokioHome, 'prototype/PTERM.md', 'Acceptance fixture\n')
|
|
440
|
+
return { root, fakeUserHome, pinokioHome }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function runServerMode() {
|
|
444
|
+
const args = parseArgs()
|
|
445
|
+
const checkout = path.resolve(String(args.checkout || ''))
|
|
446
|
+
const home = path.resolve(String(args.home || ''))
|
|
447
|
+
const port = Number(args.port)
|
|
448
|
+
process.chdir(checkout)
|
|
449
|
+
const pkg = require(path.resolve(checkout, 'package.json'))
|
|
450
|
+
const Server = require(path.resolve(checkout, 'server'))
|
|
451
|
+
const server = new Server({
|
|
452
|
+
store: { store: { home, version: pkg.version } },
|
|
453
|
+
agent: 'test',
|
|
454
|
+
newsfeed: '',
|
|
455
|
+
site: 'https://pinokio.co',
|
|
456
|
+
discover_dark: 'https://pinokio.co?embed=1&theme=dark',
|
|
457
|
+
discover_light: 'https://pinokio.co?embed=1&theme=light',
|
|
458
|
+
portal: 'https://pinokio.co'
|
|
459
|
+
})
|
|
460
|
+
server.port = port
|
|
461
|
+
await server.start({ debug: true })
|
|
462
|
+
// The acceptance fixtures need the real app UI, not the unrelated full-dev
|
|
463
|
+
// setup gate. Runtime-backed cases still execute through the real Conda shell.
|
|
464
|
+
server.kernel.bin.check = async () => ({
|
|
465
|
+
requirements: [],
|
|
466
|
+
install_required: false,
|
|
467
|
+
requirements_pending: false,
|
|
468
|
+
error: null
|
|
469
|
+
})
|
|
470
|
+
process.stdout.write(`${readyPrefix}${JSON.stringify({ checkout, home, port, pid: process.pid })}\n`)
|
|
471
|
+
setInterval(() => {}, 1000)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function waitForReady(child, timeoutMs = 90000) {
|
|
475
|
+
return await new Promise((resolve, reject) => {
|
|
476
|
+
let stdout = ''
|
|
477
|
+
let stderr = ''
|
|
478
|
+
const timer = setTimeout(() => reject(new Error(`server readiness timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`)), timeoutMs)
|
|
479
|
+
const onExit = (code, signal) => {
|
|
480
|
+
clearTimeout(timer)
|
|
481
|
+
reject(new Error(`server exited before ready: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
482
|
+
}
|
|
483
|
+
child.once('exit', onExit)
|
|
484
|
+
child.stdout.on('data', chunk => {
|
|
485
|
+
const text = chunk.toString()
|
|
486
|
+
stdout += text
|
|
487
|
+
for (const line of text.split(/\r?\n/)) {
|
|
488
|
+
if (!line.startsWith(readyPrefix)) continue
|
|
489
|
+
clearTimeout(timer)
|
|
490
|
+
child.off('exit', onExit)
|
|
491
|
+
resolve({ ready: JSON.parse(line.slice(readyPrefix.length)), stdout, stderr })
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
child.stderr.on('data', chunk => { stderr += chunk.toString() })
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function stopChild(child) {
|
|
499
|
+
if (!child || child.exitCode !== null) return
|
|
500
|
+
child.kill('SIGTERM')
|
|
501
|
+
await new Promise(resolve => {
|
|
502
|
+
const timer = setTimeout(resolve, 3000)
|
|
503
|
+
child.once('exit', () => {
|
|
504
|
+
clearTimeout(timer)
|
|
505
|
+
resolve()
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
if (child.exitCode === null) child.kill('SIGKILL')
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function waitForHttp(url, timeoutMs = 45000) {
|
|
512
|
+
const started = Date.now()
|
|
513
|
+
while (Date.now() - started < timeoutMs) {
|
|
514
|
+
try {
|
|
515
|
+
const response = await fetch(url)
|
|
516
|
+
if (response.ok) return
|
|
517
|
+
} catch (_) {}
|
|
518
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
519
|
+
}
|
|
520
|
+
throw new Error(`timed out waiting for ${url}`)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function waitForFile(file, predicate, timeoutMs = 15000) {
|
|
524
|
+
const started = Date.now()
|
|
525
|
+
while (Date.now() - started < timeoutMs) {
|
|
526
|
+
try {
|
|
527
|
+
const content = await fsp.readFile(file, 'utf8')
|
|
528
|
+
if (predicate(content)) return content
|
|
529
|
+
} catch (_) {}
|
|
530
|
+
await new Promise(resolve => setTimeout(resolve, 150))
|
|
531
|
+
}
|
|
532
|
+
throw new Error(`timed out waiting for ${file}`)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function waitForOptionalFile(file, predicate, timeoutMs = 15000) {
|
|
536
|
+
try {
|
|
537
|
+
return await waitForFile(file, predicate, timeoutMs)
|
|
538
|
+
} catch (_) {
|
|
539
|
+
return null
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function readOptional(file) {
|
|
544
|
+
try {
|
|
545
|
+
return await fsp.readFile(file, 'utf8')
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (error.code === 'ENOENT') return null
|
|
548
|
+
throw error
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function waitForBodyText(page, value, timeoutMs = 15000) {
|
|
553
|
+
const started = Date.now()
|
|
554
|
+
while (Date.now() - started < timeoutMs) {
|
|
555
|
+
const body = await page.locator('body').innerText().catch(() => '')
|
|
556
|
+
if (body.includes(value)) return true
|
|
557
|
+
await page.waitForTimeout(150)
|
|
558
|
+
}
|
|
559
|
+
return false
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function startRegistryStub() {
|
|
563
|
+
const received = []
|
|
564
|
+
const server = http.createServer((req, res) => {
|
|
565
|
+
const chunks = []
|
|
566
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
567
|
+
req.on('end', () => {
|
|
568
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
569
|
+
const match = body.match(/name="metadata_b64"\r\n\r\n([^\r\n]+)/)
|
|
570
|
+
let metadata = null
|
|
571
|
+
if (match) {
|
|
572
|
+
try { metadata = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')) } catch (_) {}
|
|
573
|
+
}
|
|
574
|
+
received.push({ method: req.method, url: req.url, authorization: req.headers.authorization || '', metadata })
|
|
575
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
576
|
+
res.end(JSON.stringify({ ok: true, draft: 'local-stub' }))
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
await new Promise((resolve, reject) => {
|
|
580
|
+
server.once('error', reject)
|
|
581
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
582
|
+
})
|
|
583
|
+
return {
|
|
584
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
585
|
+
received,
|
|
586
|
+
close: () => new Promise(resolve => server.close(resolve))
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function browserRequest(page, pathname, options = {}) {
|
|
591
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
592
|
+
try {
|
|
593
|
+
return await page.evaluate(async ({ pathname, options }) => {
|
|
594
|
+
const response = await fetch(pathname, options)
|
|
595
|
+
const text = await response.text()
|
|
596
|
+
let json = null
|
|
597
|
+
try { json = JSON.parse(text) } catch (_) {}
|
|
598
|
+
return { status: response.status, ok: response.ok, text, json }
|
|
599
|
+
}, { pathname, options })
|
|
600
|
+
} catch (error) {
|
|
601
|
+
const interrupted = /Execution context was destroyed|Cannot find context with specified id|Inspected target navigated or closed/i.test(String(error && error.message || error))
|
|
602
|
+
if (!interrupted || attempt === 2) throw error
|
|
603
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {})
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function waitForBrowserRequest(page, pathname, predicate, timeoutMs = 15000) {
|
|
609
|
+
const started = Date.now()
|
|
610
|
+
let response
|
|
611
|
+
while (Date.now() - started < timeoutMs) {
|
|
612
|
+
response = await browserRequest(page, pathname)
|
|
613
|
+
if (predicate(response)) return response
|
|
614
|
+
await page.waitForTimeout(150)
|
|
615
|
+
}
|
|
616
|
+
return response
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function stableStatus(payload) {
|
|
620
|
+
if (!payload) return null
|
|
621
|
+
return {
|
|
622
|
+
app_id: payload.app_id,
|
|
623
|
+
title: payload.title,
|
|
624
|
+
icon: payload.icon,
|
|
625
|
+
install_script: payload.install_script,
|
|
626
|
+
start_script: payload.start_script,
|
|
627
|
+
default_script: payload.default_script,
|
|
628
|
+
default_target: payload.default_target,
|
|
629
|
+
state: payload.state
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function menuSnapshot(page, selector = '.m.h.menu > .frame-link') {
|
|
634
|
+
return await page.locator(selector).evaluateAll(nodes => nodes.map(node => {
|
|
635
|
+
let action = null
|
|
636
|
+
try { action = node.dataset.action ? JSON.parse(node.dataset.action) : null } catch (_) {}
|
|
637
|
+
return {
|
|
638
|
+
text: (node.innerText || '').replace(/\s+/g, ' ').trim(),
|
|
639
|
+
href: node.getAttribute('href'),
|
|
640
|
+
run: node.dataset.run || null,
|
|
641
|
+
cwd: node.dataset.cwd || null,
|
|
642
|
+
command: node.dataset.command || null,
|
|
643
|
+
filepath: node.dataset.filepath || null,
|
|
644
|
+
action,
|
|
645
|
+
running: node.classList.contains('running') || !!node.querySelector('.fa-circle')
|
|
646
|
+
}
|
|
647
|
+
}).filter(item => !/^Settings/.test(item.text)))
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function adversarialPageSnapshot(page, baseUrl, id) {
|
|
651
|
+
const navigation = await goto(page, baseUrl, `/p/${id}`)
|
|
652
|
+
const body = await page.locator('body').innerText().catch(() => '')
|
|
653
|
+
const status = await browserRequest(page, `/apps/status/${id}`)
|
|
654
|
+
return {
|
|
655
|
+
navigation,
|
|
656
|
+
markers: ['Root With Folder', 'Root Winner', 'Nested Loser', 'Legacy Nested Fallback', 'App Named Pinokio']
|
|
657
|
+
.filter(marker => body.includes(marker)),
|
|
658
|
+
menu: await page.locator('.m.h.menu').count() ? await menuSnapshot(page) : [],
|
|
659
|
+
status: { status: status.status, value: stableStatus(status.json) }
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function adversarialRouteSnapshot(page, pathname, markers = []) {
|
|
664
|
+
const response = await browserRequest(page, pathname)
|
|
665
|
+
return {
|
|
666
|
+
status: response.status,
|
|
667
|
+
markers: markers.filter(marker => response.text.includes(marker))
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function exerciseAdversarial(page, baseUrl) {
|
|
672
|
+
const rootWithFolder = await adversarialPageSnapshot(page, baseUrl, 'root-with-folder')
|
|
673
|
+
const viewer = await browserRequest(page, '/apps/logs/root-with-folder?script=seeded.js')
|
|
674
|
+
const help = await browserRequest(page, '/apps/logs/root-with-folder/report?session=adversarial-help-session&redaction=none')
|
|
675
|
+
rootWithFolder.logViewer = {
|
|
676
|
+
status: viewer.status,
|
|
677
|
+
script: viewer.json && viewer.json.script,
|
|
678
|
+
hasRootSentinel: !!(viewer.json && String(viewer.json.text || '').includes(adversarialViewerSentinel)),
|
|
679
|
+
hasNestedSentinel: !!(viewer.json && String(viewer.json.text || '').includes(adversarialHelpSentinel))
|
|
680
|
+
}
|
|
681
|
+
rootWithFolder.getHelp = {
|
|
682
|
+
status: help.status,
|
|
683
|
+
hasRootSentinel: !!(help.json && String(help.json.markdown || '').includes(adversarialViewerSentinel)),
|
|
684
|
+
hasNestedSentinel: !!(help.json && String(help.json.markdown || '').includes(adversarialHelpSentinel))
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const subfolderUi = await adversarialRouteSnapshot(page, '/p/subfolder-project/child', ['Subfolder Parent', 'Subfolder Child'])
|
|
688
|
+
const subfolderDirectory = await adversarialRouteSnapshot(page, '/api/subfolder-project/child', ['Subfolder Child', 'Child start'])
|
|
689
|
+
const subfolderIcon = await browserRequest(page, '/asset/api/subfolder-project/child/child.png')
|
|
690
|
+
const brokenRoot = await adversarialPageSnapshot(page, baseUrl, 'broken-root')
|
|
691
|
+
brokenRoot.sidebarNavigation = await goto(page, baseUrl, '/pinokio/sidebar/broken-root')
|
|
692
|
+
brokenRoot.sidebarMenu = await menuSnapshot(page, '.frame-link')
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
rootWithFolder,
|
|
696
|
+
bothLaunchers: await adversarialPageSnapshot(page, baseUrl, 'both-launchers'),
|
|
697
|
+
brokenRoot,
|
|
698
|
+
emptyPinokioFolder: await adversarialRouteSnapshot(page, '/p/empty-pinokio-folder', ['empty-pinokio-folder']),
|
|
699
|
+
plainPinokioFile: await adversarialRouteSnapshot(page, '/p/plain-pinokio-file', ['plain-pinokio-file']),
|
|
700
|
+
noLauncher: await adversarialRouteSnapshot(page, '/p/no-launcher', ['no-launcher']),
|
|
701
|
+
appNamedPinokio: await adversarialPageSnapshot(page, baseUrl, 'pinokio'),
|
|
702
|
+
subfolder: {
|
|
703
|
+
ui: subfolderUi,
|
|
704
|
+
directory: subfolderDirectory,
|
|
705
|
+
iconStatus: subfolderIcon.status
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function waitForRunning(page, expected, running, timeoutMs = 12000, targetApp = appId) {
|
|
711
|
+
const started = Date.now()
|
|
712
|
+
while (Date.now() - started < timeoutMs) {
|
|
713
|
+
const response = await browserRequest(page, `/pinokio/home_status?t=${Date.now()}`)
|
|
714
|
+
const entries = response.json && Array.isArray(response.json.running_scripts) ? response.json.running_scripts : []
|
|
715
|
+
const found = entries.some(entry => entry && entry.app === targetApp && String(entry.script_path || entry.name || '').replace(/\\/g, '/') === expected)
|
|
716
|
+
if (found === running) return true
|
|
717
|
+
await page.waitForTimeout(150)
|
|
718
|
+
}
|
|
719
|
+
return false
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function goto(page, baseUrl, pathname) {
|
|
723
|
+
const response = await page.goto(`${baseUrl}${pathname}`, { waitUntil: 'load' })
|
|
724
|
+
return { status: response ? response.status() : 0, url: new URL(page.url()).pathname + new URL(page.url()).search }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function normalizedTreeEntries(entries) {
|
|
728
|
+
return entries.map(entry => ({ name: entry.name, path: entry.path, type: entry.type })).sort((a, b) => a.path.localeCompare(b.path))
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function exerciseRun({ page, baseUrl, home, build, layout, stub, artifacts, runtimeBacked }) {
|
|
732
|
+
const prefix = layout === 'nested' ? 'pinokio/' : ''
|
|
733
|
+
const envPath = path.resolve(home.primary.launcher, 'ENVIRONMENT')
|
|
734
|
+
const otherEnvPath = layout === 'nested' ? path.resolve(home.primary.workspace, 'ENVIRONMENT') : path.resolve(home.primary.workspace, 'pinokio', 'ENVIRONMENT')
|
|
735
|
+
const envWritePath = build === 'baseline' && layout === 'nested' ? otherEnvPath : envPath
|
|
736
|
+
const trace = { build, layout, runtime_backed: runtimeBacked
|
|
737
|
+
? { status: 'EXECUTED' }
|
|
738
|
+
: { 4: 'UNVERIFIED: no test-owned runtime supplied', 16: 'UNVERIFIED: no test-owned runtime supplied', 23: 'UNVERIFIED: no test-owned runtime supplied', tier6: 'UNVERIFIED: live Explore installation is a separate release test' } }
|
|
739
|
+
|
|
740
|
+
trace.home = await goto(page, baseUrl, '/home')
|
|
741
|
+
await page.locator(`.home-app-line[data-uri="${appId}"]`).waitFor()
|
|
742
|
+
trace.homeCard = await page.locator(`.home-app-line[data-uri="${appId}"]`).evaluate(node => ({
|
|
743
|
+
href: node.getAttribute('data-href'),
|
|
744
|
+
icon: node.getAttribute('data-icon'),
|
|
745
|
+
iconpath: node.getAttribute('data-iconpath'),
|
|
746
|
+
name: node.getAttribute('data-name')
|
|
747
|
+
}))
|
|
748
|
+
|
|
749
|
+
trace.app = await goto(page, baseUrl, `/p/${appId}`)
|
|
750
|
+
await page.locator('.m.h.menu').waitFor()
|
|
751
|
+
trace.menuBefore = await menuSnapshot(page)
|
|
752
|
+
const startItem = trace.menuBefore.find(item => item.text.includes('Start relative'))
|
|
753
|
+
const runItem = trace.menuBefore.find(item => item.text.includes('Run string'))
|
|
754
|
+
trace.runCwd = runItem ? runItem.cwd : null
|
|
755
|
+
if (runtimeBacked) {
|
|
756
|
+
const runFile = path.resolve(home.primary.launcher, 'run-cwd.txt')
|
|
757
|
+
const runButton = page.locator('.m.h.menu [data-run]', { hasText: 'Run string' }).first()
|
|
758
|
+
if (await runButton.count()) await runButton.click()
|
|
759
|
+
trace.runtime_backed.run_string = {
|
|
760
|
+
clicked: !!(await runButton.count()),
|
|
761
|
+
output: await waitForOptionalFile(runFile, value => !!value.trim(), 30000)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
trace.status = await waitForBrowserRequest(page, `/apps/status/${appId}`, response => response.json && response.json.state === 'starting')
|
|
766
|
+
assert.equal(trace.status.json && trace.status.json.state, 'starting', 'opening the app did not start its default script')
|
|
767
|
+
trace.status = { status: trace.status.status, value: stableStatus(trace.status.json) }
|
|
768
|
+
const expectedStart = trace.status.value && trace.status.value.start_script ? trace.status.value.start_script : `${prefix}start.js`
|
|
769
|
+
trace.autolaunch = await browserRequest(page, `/autolaunch/candidates?app=${encodeURIComponent(appId)}`)
|
|
770
|
+
if (trace.autolaunch.json) {
|
|
771
|
+
trace.autolaunch = {
|
|
772
|
+
status: trace.autolaunch.status,
|
|
773
|
+
menu: (trace.autolaunch.json.menu || []).map(item => item.script),
|
|
774
|
+
other: (trace.autolaunch.json.other || []).map(item => item.script)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
trace.startNavigation = startItem ? await goto(page, baseUrl, startItem.href) : { status: 0, url: '' }
|
|
779
|
+
trace.startRunning = await waitForRunning(page, expectedStart, true)
|
|
780
|
+
await goto(page, baseUrl, `/p/${appId}`)
|
|
781
|
+
trace.menuRunning = await menuSnapshot(page)
|
|
782
|
+
const stopItem = page.locator('.m.h.menu [data-action]', { hasText: 'Stop relative' }).first()
|
|
783
|
+
if (await stopItem.count()) await stopItem.click()
|
|
784
|
+
trace.menuStopWorked = await waitForRunning(page, expectedStart, false)
|
|
785
|
+
|
|
786
|
+
if (startItem) await goto(page, baseUrl, startItem.href)
|
|
787
|
+
trace.shortcutStartRunning = await waitForRunning(page, expectedStart, true)
|
|
788
|
+
await goto(page, baseUrl, '/home')
|
|
789
|
+
const shortcut = page.locator(`.home-app-line[data-uri="${appId}"] .menu-btn[data-action]`).first()
|
|
790
|
+
trace.shortcutAction = await shortcut.count() ? await shortcut.getAttribute('data-action') : null
|
|
791
|
+
trace.shortcutElement = await shortcut.count() ? await shortcut.evaluate(node => node.outerHTML) : null
|
|
792
|
+
await page.waitForTimeout(250)
|
|
793
|
+
if (await shortcut.count()) await shortcut.click()
|
|
794
|
+
trace.shortcutFirstClickWorked = await waitForRunning(page, expectedStart, false, 750)
|
|
795
|
+
if (!trace.shortcutFirstClickWorked && await shortcut.count()) await shortcut.click()
|
|
796
|
+
trace.shortcutStopWorked = trace.shortcutFirstClickWorked || await waitForRunning(page, expectedStart, false)
|
|
797
|
+
|
|
798
|
+
trace.envPage = await goto(page, baseUrl, `/env/api/${appId}?init=true`)
|
|
799
|
+
await page.locator('#customize').click()
|
|
800
|
+
await page.locator('#REQUIRED_VALUE').fill('saved-through-browser')
|
|
801
|
+
const envResponsePromise = page.waitForResponse(response => response.url() === `${baseUrl}/env` && response.request().method() === 'POST')
|
|
802
|
+
await page.locator('#save').click()
|
|
803
|
+
const envResponse = await envResponsePromise
|
|
804
|
+
trace.envPost = envResponse.status()
|
|
805
|
+
const baselineNestedRepair = build === 'baseline' && layout === 'nested'
|
|
806
|
+
if (!baselineNestedRepair) {
|
|
807
|
+
await waitForFile(envWritePath, content => content.includes('REQUIRED_VALUE=saved-through-browser'))
|
|
808
|
+
}
|
|
809
|
+
trace.env = { launcher: await readOptional(envPath), other: await readOptional(otherEnvPath) }
|
|
810
|
+
|
|
811
|
+
trace.envSetNavigation = await goto(page, baseUrl, `/api/${appId}/${prefix}env-set.js`)
|
|
812
|
+
if (!baselineNestedRepair) {
|
|
813
|
+
await waitForFile(envWritePath, content => content.includes('ACTION_VALUE=set-by-action'))
|
|
814
|
+
}
|
|
815
|
+
trace.envSwitchNavigation = await goto(page, baseUrl, `/api/${appId}/${prefix}env-switch.js`)
|
|
816
|
+
if (!baselineNestedRepair) {
|
|
817
|
+
await waitForFile(envWritePath, content => content.includes('SWITCH_VALUE=two'))
|
|
818
|
+
}
|
|
819
|
+
trace.envAfterActions = await readOptional(envWritePath)
|
|
820
|
+
|
|
821
|
+
if (runtimeBacked) {
|
|
822
|
+
const runtimeResultPath = path.resolve(home.primary.launcher, 'runtime-result.json')
|
|
823
|
+
trace.runtime_backed.script_navigation = await goto(page, baseUrl, `/api/${appId}/${prefix}runtime.js`)
|
|
824
|
+
const runtimeResult = await waitForOptionalFile(runtimeResultPath, value => value.includes(runtimeSentinel), 30000)
|
|
825
|
+
trace.runtime_backed.terminal_has_sentinel = await waitForBodyText(page, runtimeSentinel, 5000)
|
|
826
|
+
trace.runtime_backed.result = runtimeResult ? JSON.parse(runtimeResult) : null
|
|
827
|
+
const runtimeLog = await waitForBrowserRequest(
|
|
828
|
+
page,
|
|
829
|
+
`/apps/logs/${appId}?script=${encodeURIComponent(`${prefix}runtime.js`)}`,
|
|
830
|
+
response => !!(response.json && String(response.json.text || '').includes(runtimeSentinel)),
|
|
831
|
+
build === 'baseline' && layout === 'nested' ? 1000 : 15000
|
|
832
|
+
)
|
|
833
|
+
trace.runtime_backed.log = runtimeLog.json ? {
|
|
834
|
+
status: runtimeLog.status,
|
|
835
|
+
script: runtimeLog.json.script,
|
|
836
|
+
has_sentinel: String(runtimeLog.json.text || '').includes(runtimeSentinel)
|
|
837
|
+
} : { status: runtimeLog.status, has_sentinel: false, error: runtimeLog.text }
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
trace.share = await goto(page, baseUrl, `/share/${appId}`)
|
|
841
|
+
trace.shareValues = await page.locator('.env-item').evaluateAll(nodes => nodes.map(node => ({
|
|
842
|
+
key: (node.querySelector('label') || {}).innerText || '',
|
|
843
|
+
value: (node.querySelector('input') || {}).value || ''
|
|
844
|
+
})))
|
|
845
|
+
|
|
846
|
+
trace.inheritancePage = await goto(page, baseUrl, `/env/api/${inheritanceId}?init=true`)
|
|
847
|
+
trace.inheritance = {
|
|
848
|
+
launcher: await readOptional(path.resolve(home.inheritance.launcher, 'ENVIRONMENT')),
|
|
849
|
+
other: await readOptional(layout === 'nested' ? path.resolve(home.inheritance.workspace, 'ENVIRONMENT') : path.resolve(home.inheritance.workspace, 'pinokio', 'ENVIRONMENT'))
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
trace.iconBefore = await browserRequest(page, trace.status.value.icon)
|
|
853
|
+
trace.iconBefore = { status: trace.iconBefore.status, size: trace.iconBefore.text.length }
|
|
854
|
+
trace.iconUpload = await page.evaluate(async ({ appId, pngBase64 }) => {
|
|
855
|
+
const form = new FormData()
|
|
856
|
+
form.append('avatar', new File([Uint8Array.from(atob(pngBase64), value => value.charCodeAt(0))], 'uploaded.png', { type: 'image/png' }))
|
|
857
|
+
form.append('icon_dirty', 'true')
|
|
858
|
+
form.append('icon_path', 'uploaded.png')
|
|
859
|
+
form.append('title', 'Nested Browser Acceptance')
|
|
860
|
+
form.append('description', 'Controlled nested-launcher browser fixture.')
|
|
861
|
+
form.append('old_path', appId)
|
|
862
|
+
form.append('new_path', appId)
|
|
863
|
+
form.append('edit', 'true')
|
|
864
|
+
const response = await fetch('/pinokio/upload', { method: 'POST', body: form })
|
|
865
|
+
return { status: response.status, text: await response.text() }
|
|
866
|
+
}, { appId, pngBase64: png.toString('base64') })
|
|
867
|
+
trace.uploadedIcon = {
|
|
868
|
+
launcher: fs.existsSync(path.resolve(home.primary.launcher, 'uploaded.png')),
|
|
869
|
+
other: fs.existsSync(layout === 'nested' ? path.resolve(home.primary.workspace, 'uploaded.png') : path.resolve(home.primary.workspace, 'pinokio', 'uploaded.png'))
|
|
870
|
+
}
|
|
871
|
+
const refreshedStatus = await browserRequest(page, `/apps/status/${appId}`)
|
|
872
|
+
trace.iconAfter = { url: refreshedStatus.json && refreshedStatus.json.icon }
|
|
873
|
+
if (trace.iconAfter.url) {
|
|
874
|
+
const iconAfterResponse = await browserRequest(page, trace.iconAfter.url)
|
|
875
|
+
trace.iconAfter.status = iconAfterResponse.status
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
trace.preFirst = await goto(page, baseUrl, `/pre/api/${appId}`)
|
|
879
|
+
trace.preFirst.items = await page.locator('.item').evaluateAll(nodes => nodes.map(node => ({
|
|
880
|
+
title: (node.querySelector('.title') || {}).innerText || '',
|
|
881
|
+
icon: (node.querySelector('img') || {}).getAttribute ? node.querySelector('img').getAttribute('src') : null,
|
|
882
|
+
href: (node.querySelector('a') || {}).getAttribute ? node.querySelector('a').getAttribute('href') : null,
|
|
883
|
+
filepath: (node.querySelector('button') || {}).dataset ? node.querySelector('button').dataset.filepath || null : null
|
|
884
|
+
})))
|
|
885
|
+
trace.preSecond = await goto(page, baseUrl, `/pre/api/${appId}`)
|
|
886
|
+
trace.preSecond.items = await page.locator('.item').evaluateAll(nodes => nodes.map(node => ({
|
|
887
|
+
title: (node.querySelector('.title') || {}).innerText || '',
|
|
888
|
+
icon: (node.querySelector('img') || {}).getAttribute ? node.querySelector('img').getAttribute('src') : null,
|
|
889
|
+
href: (node.querySelector('a') || {}).getAttribute ? node.querySelector('a').getAttribute('href') : null,
|
|
890
|
+
filepath: (node.querySelector('button') || {}).dataset ? node.querySelector('button').dataset.filepath || null : null
|
|
891
|
+
})))
|
|
892
|
+
|
|
893
|
+
trace.search = {}
|
|
894
|
+
for (const token of ['WORKSPACEREADMESENTINEL', 'LAUNCHERREADMESENTINEL', 'SCRIPTSEARCHSENTINEL']) {
|
|
895
|
+
const response = await browserRequest(page, `/apps/search?q=${encodeURIComponent(token)}&mode=strict&min_match=1`)
|
|
896
|
+
trace.search[token] = {
|
|
897
|
+
status: response.status,
|
|
898
|
+
files: response.json && Array.isArray(response.json.apps)
|
|
899
|
+
? response.json.apps.flatMap(app => (app.matches || []).map(match => match.file)).sort()
|
|
900
|
+
: []
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const aiResponse = await browserRequest(page, `/pinokio/ai/${appId}`)
|
|
904
|
+
trace.ai = { status: aiResponse.status }
|
|
905
|
+
await page.setContent(aiResponse.text)
|
|
906
|
+
trace.ai.links = await page.locator('a').evaluateAll(nodes => nodes.map(node => ({ text: node.innerText.trim(), href: node.getAttribute('href') })).sort((a, b) => a.href.localeCompare(b.href)))
|
|
907
|
+
for (const link of trace.ai.links) {
|
|
908
|
+
const response = await browserRequest(page, link.href)
|
|
909
|
+
link.status = response.status
|
|
910
|
+
link.body = response.text.trim()
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
await goto(page, baseUrl, `/p/${appId}`)
|
|
914
|
+
const logQuery = layout === 'nested' ? 'pinokio/seeded.js' : 'seeded.js'
|
|
915
|
+
const currentLog = await browserRequest(page, `/apps/logs/${appId}?script=${encodeURIComponent(logQuery)}`)
|
|
916
|
+
trace.currentLog = currentLog.json ? {
|
|
917
|
+
status: currentLog.status,
|
|
918
|
+
script: currentLog.json.script,
|
|
919
|
+
file: currentLog.json.file,
|
|
920
|
+
text: currentLog.json.text
|
|
921
|
+
} : { status: currentLog.status, error: currentLog.text }
|
|
922
|
+
const logsPage = await goto(page, baseUrl, `/logs?workspace=${appId}`)
|
|
923
|
+
trace.logsPage = { ...logsPage, root: await page.locator('[data-logs-root]').count() ? await page.locator('[data-logs-root]').getAttribute('data-logs-root') : null }
|
|
924
|
+
const tree = await browserRequest(page, `/api/logs/tree?workspace=${appId}`)
|
|
925
|
+
trace.logTree = tree.json ? { status: tree.status, path: tree.json.path, entries: normalizedTreeEntries(tree.json.entries || []) } : { status: tree.status, error: tree.text }
|
|
926
|
+
|
|
927
|
+
const reportResponse = await browserRequest(page, `/apps/logs/${appId}/report?session=${historicalSession}&redaction=none`)
|
|
928
|
+
trace.report = reportResponse.json ? {
|
|
929
|
+
status: reportResponse.status,
|
|
930
|
+
session: reportResponse.json.session,
|
|
931
|
+
sections: (reportResponse.json.sections || []).map(section => ({ source: section.source, script: section.script, file: section.file, text: section.text })),
|
|
932
|
+
hasHistorical: String(reportResponse.json.markdown || '').includes(historicalSentinel),
|
|
933
|
+
hasCurrent: String(reportResponse.json.markdown || '').includes(currentSentinel),
|
|
934
|
+
repo: reportResponse.json.repo_url || null
|
|
935
|
+
} : { status: reportResponse.status, error: reportResponse.text }
|
|
936
|
+
if (reportResponse.json) {
|
|
937
|
+
const metadata = Buffer.from(JSON.stringify({
|
|
938
|
+
body: reportResponse.json.markdown,
|
|
939
|
+
appRepoUrl: reportResponse.json.repo_url,
|
|
940
|
+
session: historicalSession
|
|
941
|
+
}), 'utf8').toString('base64')
|
|
942
|
+
trace.draft = await page.evaluate(async ({ appId, registry, metadata }) => {
|
|
943
|
+
const form = new FormData()
|
|
944
|
+
form.append('token', 'acceptance-token')
|
|
945
|
+
form.append('registry', registry)
|
|
946
|
+
form.append('metadata_b64', metadata)
|
|
947
|
+
const response = await fetch(`/apps/logs/${appId}/drafts`, { method: 'POST', body: form })
|
|
948
|
+
return { status: response.status, text: await response.text() }
|
|
949
|
+
}, { appId, registry: stub.url, metadata })
|
|
950
|
+
} else {
|
|
951
|
+
trace.draft = { status: 0, text: '' }
|
|
952
|
+
}
|
|
953
|
+
trace.registryDraft = stub.received.map(item => ({
|
|
954
|
+
method: item.method,
|
|
955
|
+
url: item.url,
|
|
956
|
+
authorization: item.authorization,
|
|
957
|
+
hasHistorical: !!(item.metadata && item.metadata.body && item.metadata.body.includes(historicalSentinel)),
|
|
958
|
+
hasCurrent: !!(item.metadata && item.metadata.body && item.metadata.body.includes(currentSentinel)),
|
|
959
|
+
repo: item.metadata && (item.metadata.appRepoUrl || item.metadata.repoUrl)
|
|
960
|
+
}))
|
|
961
|
+
|
|
962
|
+
trace.gitExclude = await readOptional(path.resolve(home.primary.workspace, '.git', 'info', 'exclude'))
|
|
963
|
+
trace.inheritanceGitExclude = await readOptional(path.resolve(home.inheritance.workspace, '.git', 'info', 'exclude'))
|
|
964
|
+
trace.locations = {
|
|
965
|
+
env: path.relative(home.pinokioHome, envPath).split(path.sep).join('/'),
|
|
966
|
+
logs: path.relative(home.pinokioHome, path.resolve(home.primary.launcher, 'logs')).split(path.sep).join('/'),
|
|
967
|
+
icon: trace.uploadedIcon.launcher ? path.relative(home.pinokioHome, path.resolve(home.primary.launcher, 'uploaded.png')).split(path.sep).join('/') : null
|
|
968
|
+
}
|
|
969
|
+
trace.nestedChildRegression = null
|
|
970
|
+
if (layout === 'nested') {
|
|
971
|
+
await goto(page, baseUrl, `/p/${nestedChildId}`)
|
|
972
|
+
const nestedChildMenu = await menuSnapshot(page)
|
|
973
|
+
const start = nestedChildMenu.find(item => item.text.includes('Start nested child'))
|
|
974
|
+
const stop = nestedChildMenu.find(item => item.text.includes('Stop nested child'))
|
|
975
|
+
const candidates = await browserRequest(page, `/autolaunch/candidates?app=${nestedChildId}`)
|
|
976
|
+
if (start) await goto(page, baseUrl, start.href)
|
|
977
|
+
const started = await waitForRunning(page, 'pinokio/pinokio/tool.js', true, 12000, nestedChildId)
|
|
978
|
+
await goto(page, baseUrl, `/p/${nestedChildId}`)
|
|
979
|
+
const stopButton = page.locator('.m.h.menu [data-action]', { hasText: 'Stop nested child' }).first()
|
|
980
|
+
if (await stopButton.count()) await stopButton.click()
|
|
981
|
+
trace.nestedChildRegression = {
|
|
982
|
+
startHref: start && start.href,
|
|
983
|
+
stopUri: stop && stop.action && stop.action.uri,
|
|
984
|
+
candidates: candidates.json && (candidates.json.menu || []).map(item => item.script),
|
|
985
|
+
started,
|
|
986
|
+
stopped: await waitForRunning(page, 'pinokio/pinokio/tool.js', false, 12000, nestedChildId)
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
trace.adversarial = await exerciseAdversarial(page, baseUrl)
|
|
990
|
+
await page.screenshot({ path: path.resolve(artifacts, 'final.png'), fullPage: true })
|
|
991
|
+
return trace
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function executeCase({ checkout, build, layout, playwright, executablePath, artifactsRoot, runtime }) {
|
|
995
|
+
const home = await createHome(layout, runtime && runtime.bin)
|
|
996
|
+
const artifacts = path.resolve(artifactsRoot, `${build}-${layout}`)
|
|
997
|
+
await fsp.mkdir(artifacts, { recursive: true })
|
|
998
|
+
const port = await freePort()
|
|
999
|
+
const stub = await startRegistryStub()
|
|
1000
|
+
const env = {
|
|
1001
|
+
...process.env,
|
|
1002
|
+
HOME: home.fakeUserHome,
|
|
1003
|
+
PINOKIO_HOME: home.pinokioHome,
|
|
1004
|
+
PINOKIO_DISABLE_WATCH: '1',
|
|
1005
|
+
NODE_PATH: [path.resolve(repoRoot, 'node_modules'), process.env.NODE_PATH || ''].filter(Boolean).join(path.delimiter)
|
|
1006
|
+
}
|
|
1007
|
+
if (process.platform === 'win32') {
|
|
1008
|
+
env.USERPROFILE = home.fakeUserHome
|
|
1009
|
+
env.APPDATA = path.resolve(home.fakeUserHome, 'AppData', 'Roaming')
|
|
1010
|
+
env.LOCALAPPDATA = path.resolve(home.fakeUserHome, 'AppData', 'Local')
|
|
1011
|
+
}
|
|
1012
|
+
const serverArgs = [
|
|
1013
|
+
__filename,
|
|
1014
|
+
'--server',
|
|
1015
|
+
'--checkout', checkout,
|
|
1016
|
+
'--home', home.pinokioHome,
|
|
1017
|
+
'--port', String(port)
|
|
1018
|
+
]
|
|
1019
|
+
if (!runtime) serverArgs.push('--runtime-free')
|
|
1020
|
+
const child = childProcess.spawn(process.execPath, serverArgs, { cwd: checkout, env, stdio: ['ignore', 'pipe', 'pipe'], detached: false })
|
|
1021
|
+
const logs = []
|
|
1022
|
+
child.stdout.on('data', chunk => logs.push(chunk.toString()))
|
|
1023
|
+
child.stderr.on('data', chunk => logs.push(chunk.toString()))
|
|
1024
|
+
let browser
|
|
1025
|
+
let page
|
|
1026
|
+
try {
|
|
1027
|
+
await waitForReady(child)
|
|
1028
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
1029
|
+
await waitForHttp(`${baseUrl}/home`)
|
|
1030
|
+
browser = await playwright.chromium.launch({ headless: true, executablePath })
|
|
1031
|
+
page = await browser.newPage()
|
|
1032
|
+
page.setDefaultTimeout(20000)
|
|
1033
|
+
const diagnostics = { pageErrors: [], consoleErrors: [], requestFailures: [], httpErrors: [] }
|
|
1034
|
+
page.on('pageerror', error => diagnostics.pageErrors.push(error.message || String(error)))
|
|
1035
|
+
page.on('console', message => {
|
|
1036
|
+
if (message.type() === 'error') diagnostics.consoleErrors.push(message.text())
|
|
1037
|
+
})
|
|
1038
|
+
page.on('requestfailed', request => {
|
|
1039
|
+
if (request.url().startsWith(baseUrl)) diagnostics.requestFailures.push({ url: request.url(), error: request.failure() && request.failure().errorText })
|
|
1040
|
+
})
|
|
1041
|
+
page.on('response', response => {
|
|
1042
|
+
if (response.url().startsWith(baseUrl) && response.status() >= 400) diagnostics.httpErrors.push({ url: response.url(), status: response.status() })
|
|
1043
|
+
})
|
|
1044
|
+
const trace = await exerciseRun({ page, baseUrl, home, build, layout, stub, artifacts, runtimeBacked: !!runtime })
|
|
1045
|
+
trace.diagnostics = diagnostics
|
|
1046
|
+
await fsp.writeFile(path.resolve(artifacts, 'trace.json'), JSON.stringify(trace, null, 2))
|
|
1047
|
+
await fsp.writeFile(path.resolve(artifacts, 'server.log'), logs.join(''))
|
|
1048
|
+
const unexpectedPageErrors = diagnostics.pageErrors.filter(message => {
|
|
1049
|
+
return !(build === 'baseline' && layout === 'nested' && String(message).startsWith("Unexpected token '<'"))
|
|
1050
|
+
})
|
|
1051
|
+
assert.deepEqual(unexpectedPageErrors, [], 'unexpected uncaught page errors')
|
|
1052
|
+
assert.deepEqual(diagnostics.requestFailures.filter(entry => entry.error !== 'net::ERR_ABORTED'), [], 'failed local browser requests')
|
|
1053
|
+
const knownBaselineNestedError = entry => {
|
|
1054
|
+
if (!(build === 'baseline' && layout === 'nested')) return false
|
|
1055
|
+
const url = new URL(entry.url)
|
|
1056
|
+
if (entry.status === 500 && url.pathname === '/env') return true
|
|
1057
|
+
if (entry.status === 404 && url.pathname === `/api/${appId}/pre.png`) return true
|
|
1058
|
+
if (entry.status !== 404 || url.pathname !== `/apps/logs/${appId}`) return false
|
|
1059
|
+
return ['pinokio/start.js', 'pinokio/seeded.js', 'pinokio/runtime.js'].includes(url.searchParams.get('script'))
|
|
1060
|
+
}
|
|
1061
|
+
const knownTier4Error = entry => {
|
|
1062
|
+
const url = new URL(entry.url)
|
|
1063
|
+
return (entry.status === 404 && url.pathname === '/p/subfolder-project/child') ||
|
|
1064
|
+
(entry.status === 422 && url.pathname === '/p/broken-root') ||
|
|
1065
|
+
(entry.status === 500 && url.pathname === '/p/plain-pinokio-file')
|
|
1066
|
+
}
|
|
1067
|
+
assert.deepEqual(diagnostics.httpErrors.filter(entry => !knownBaselineNestedError(entry) && !knownTier4Error(entry)), [], 'unexpected local 4xx/5xx responses')
|
|
1068
|
+
if (build === 'baseline' && layout === 'nested') {
|
|
1069
|
+
assert.equal(diagnostics.consoleErrors.every(message => /^Failed to load resource: the server responded with a status of (404|422|500)/.test(message)), true, 'unexpected baseline nested console error')
|
|
1070
|
+
} else {
|
|
1071
|
+
assert.equal(diagnostics.consoleErrors.every(message => /^Failed to load resource: the server responded with a status of (404|422|500)/.test(message)), true, 'unexpected browser console error')
|
|
1072
|
+
}
|
|
1073
|
+
return { trace, artifacts, temporaryHome: home.root, browserVersion: browser.version(), server: { pid: child.pid, port } }
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
if (page) await page.screenshot({ path: path.resolve(artifacts, 'failure.png'), fullPage: true }).catch(() => {})
|
|
1076
|
+
await fsp.writeFile(path.resolve(artifacts, 'server.log'), logs.join('')).catch(() => {})
|
|
1077
|
+
await fsp.writeFile(path.resolve(artifacts, 'failure.txt'), error && error.stack ? error.stack : String(error)).catch(() => {})
|
|
1078
|
+
error.artifacts = artifacts
|
|
1079
|
+
error.temporaryHome = home.root
|
|
1080
|
+
throw error
|
|
1081
|
+
} finally {
|
|
1082
|
+
if (page) await page.close().catch(() => {})
|
|
1083
|
+
if (browser) await browser.close().catch(() => {})
|
|
1084
|
+
await stopChild(child)
|
|
1085
|
+
await stub.close().catch(() => {})
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function waitForScriptCompletion(page, app, scriptPath, logFile, initiallyRunning = false, timeoutMs = 600000) {
|
|
1090
|
+
const started = Date.now()
|
|
1091
|
+
let sawRunning = initiallyRunning
|
|
1092
|
+
while (Date.now() - started < timeoutMs) {
|
|
1093
|
+
const response = await browserRequest(page, `/pinokio/home_status?t=${Date.now()}`)
|
|
1094
|
+
const entries = response.json && Array.isArray(response.json.running_scripts) ? response.json.running_scripts : []
|
|
1095
|
+
const running = entries.some(entry => entry && entry.app === app && String(entry.script_path || '').replace(/\\/g, '/') === scriptPath)
|
|
1096
|
+
sawRunning ||= running
|
|
1097
|
+
if (!running && sawRunning) return { completed: true, sawRunning, logCreated: fs.existsSync(logFile) }
|
|
1098
|
+
await page.waitForTimeout(500)
|
|
1099
|
+
}
|
|
1100
|
+
return { completed: false, sawRunning }
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function openPublishedControl(page, baseUrl, control) {
|
|
1104
|
+
await goto(page, baseUrl, '/home')
|
|
1105
|
+
const exploreButton = page.locator('[data-pinokio-explore-nav="true"]').first()
|
|
1106
|
+
await exploreButton.waitFor()
|
|
1107
|
+
await exploreButton.click()
|
|
1108
|
+
await page.waitForURL(url => url.origin === baseUrl && url.searchParams.get('mode') === 'explore', { timeout: 30000 })
|
|
1109
|
+
const frameElement = page.locator('iframe[name="pinokio-explore"]')
|
|
1110
|
+
await frameElement.waitFor()
|
|
1111
|
+
const frame = page.frame({ name: 'pinokio-explore' })
|
|
1112
|
+
assert.ok(frame, 'Explore iframe was not created')
|
|
1113
|
+
await frame.waitForLoadState('domcontentloaded')
|
|
1114
|
+
const toggle = frame.locator('button.search-toggle').first()
|
|
1115
|
+
if (await toggle.count()) await toggle.click()
|
|
1116
|
+
const search = frame.locator('form[role="search"] input[name="q"]').first()
|
|
1117
|
+
await search.waitFor({ timeout: 60000 })
|
|
1118
|
+
await search.fill(control.repo)
|
|
1119
|
+
await search.press('Enter')
|
|
1120
|
+
const result = frame.locator(`a[href="/apps/${control.slug}"]`).first()
|
|
1121
|
+
await Promise.race([
|
|
1122
|
+
frame.waitForURL(url => url.pathname === `/apps/${control.slug}`, { timeout: 60000 }),
|
|
1123
|
+
result.waitFor({ timeout: 60000 })
|
|
1124
|
+
])
|
|
1125
|
+
if (new URL(frame.url()).pathname !== `/apps/${control.slug}`) await result.click()
|
|
1126
|
+
await frame.waitForURL(url => url.pathname === `/apps/${control.slug}`, { timeout: 60000 })
|
|
1127
|
+
const installTrigger = frame.locator('button.install-trigger:visible').first()
|
|
1128
|
+
await installTrigger.waitFor()
|
|
1129
|
+
await installTrigger.click()
|
|
1130
|
+
const installAction = frame.locator('.install-drawer-backdrop.is-open a[href^="http://localhost:42000/pinokio/download?uri="]', { hasText: 'Install latest' }).first()
|
|
1131
|
+
await installAction.waitFor()
|
|
1132
|
+
const handoff = await installAction.getAttribute('href')
|
|
1133
|
+
await installAction.click()
|
|
1134
|
+
await page.waitForURL(url => url.origin === baseUrl && url.searchParams.get('mode') === 'download', { timeout: 30000 })
|
|
1135
|
+
return {
|
|
1136
|
+
explorePath: new URL(frame.url()).pathname,
|
|
1137
|
+
handoff,
|
|
1138
|
+
parentPath: new URL(page.url()).pathname + new URL(page.url()).search
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function latestSession(launcher) {
|
|
1143
|
+
const index = await readOptional(path.resolve(launcher, 'logs', 'sessions', 'index.json'))
|
|
1144
|
+
if (!index) return null
|
|
1145
|
+
try {
|
|
1146
|
+
const parsed = JSON.parse(index)
|
|
1147
|
+
return parsed.latest_session || (parsed.sessions && parsed.sessions[0] && parsed.sessions[0].id) || null
|
|
1148
|
+
} catch (_) {
|
|
1149
|
+
return null
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function executeLiveCase({ checkout, build, controlName, control, playwright, executablePath, artifactsRoot, runtime }) {
|
|
1154
|
+
const home = await createLiveHome(runtime.bin)
|
|
1155
|
+
const artifacts = path.resolve(artifactsRoot, `live-${build}-${controlName}`)
|
|
1156
|
+
await fsp.mkdir(artifacts, { recursive: true })
|
|
1157
|
+
const port = await freePort()
|
|
1158
|
+
const env = {
|
|
1159
|
+
...process.env,
|
|
1160
|
+
HOME: home.fakeUserHome,
|
|
1161
|
+
PINOKIO_HOME: home.pinokioHome,
|
|
1162
|
+
PINOKIO_DISABLE_WATCH: '1',
|
|
1163
|
+
NODE_PATH: [path.resolve(repoRoot, 'node_modules'), process.env.NODE_PATH || ''].filter(Boolean).join(path.delimiter)
|
|
1164
|
+
}
|
|
1165
|
+
if (process.platform === 'win32') {
|
|
1166
|
+
env.USERPROFILE = home.fakeUserHome
|
|
1167
|
+
env.APPDATA = path.resolve(home.fakeUserHome, 'AppData', 'Roaming')
|
|
1168
|
+
env.LOCALAPPDATA = path.resolve(home.fakeUserHome, 'AppData', 'Local')
|
|
1169
|
+
}
|
|
1170
|
+
const child = childProcess.spawn(process.execPath, [
|
|
1171
|
+
__filename,
|
|
1172
|
+
'--server',
|
|
1173
|
+
'--checkout', checkout,
|
|
1174
|
+
'--home', home.pinokioHome,
|
|
1175
|
+
'--port', String(port)
|
|
1176
|
+
], { cwd: checkout, env, stdio: ['ignore', 'pipe', 'pipe'], detached: false })
|
|
1177
|
+
const logs = []
|
|
1178
|
+
child.stdout.on('data', chunk => logs.push(chunk.toString()))
|
|
1179
|
+
child.stderr.on('data', chunk => logs.push(chunk.toString()))
|
|
1180
|
+
let browser
|
|
1181
|
+
let page
|
|
1182
|
+
const trace = { build, control: controlName, published: control, runtime: { conda: runtime.conda, node: runtime.node, git: runtime.git, uv: runtime.uv } }
|
|
1183
|
+
try {
|
|
1184
|
+
await waitForReady(child)
|
|
1185
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
1186
|
+
await waitForHttp(`${baseUrl}/home`)
|
|
1187
|
+
browser = await playwright.chromium.launch({ headless: true, executablePath })
|
|
1188
|
+
page = await browser.newPage()
|
|
1189
|
+
page.setDefaultTimeout(30000)
|
|
1190
|
+
const diagnostics = { pageErrors: [], consoleErrors: [], requestFailures: [], localHttpErrors: [] }
|
|
1191
|
+
page.on('pageerror', error => diagnostics.pageErrors.push(error.message || String(error)))
|
|
1192
|
+
page.on('console', message => {
|
|
1193
|
+
if (message.type() === 'error') diagnostics.consoleErrors.push(message.text())
|
|
1194
|
+
})
|
|
1195
|
+
page.on('requestfailed', request => diagnostics.requestFailures.push({ url: request.url(), error: request.failure() && request.failure().errorText }))
|
|
1196
|
+
page.on('response', response => {
|
|
1197
|
+
if (response.url().startsWith(baseUrl) && response.status() >= 400) diagnostics.localHttpErrors.push({ url: response.url(), status: response.status() })
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
trace.explore = await openPublishedControl(page, baseUrl, control)
|
|
1201
|
+
const modalInput = page.locator('.swal2-input').first()
|
|
1202
|
+
await modalInput.waitFor({ timeout: 30000 })
|
|
1203
|
+
trace.downloadName = await modalInput.inputValue()
|
|
1204
|
+
assert.equal(trace.downloadName, control.folder)
|
|
1205
|
+
await page.locator('.swal2-confirm').click()
|
|
1206
|
+
await page.waitForURL(url => url.pathname === `/env/api/${control.folder}` && url.searchParams.get('init') === 'true', { timeout: 240000 })
|
|
1207
|
+
|
|
1208
|
+
const workspace = path.resolve(home.pinokioHome, 'api', control.folder)
|
|
1209
|
+
const launcher = control.layout === 'nested' ? path.resolve(workspace, 'pinokio') : workspace
|
|
1210
|
+
trace.clone = {
|
|
1211
|
+
remote: run('git', ['config', '--get', 'remote.origin.url'], { cwd: workspace }).replace(/\.git$/, ''),
|
|
1212
|
+
commit: run('git', ['rev-parse', 'HEAD'], { cwd: workspace }),
|
|
1213
|
+
rootManifest: fs.existsSync(path.resolve(workspace, 'pinokio.js')),
|
|
1214
|
+
nestedManifest: fs.existsSync(path.resolve(workspace, 'pinokio', 'pinokio.js'))
|
|
1215
|
+
}
|
|
1216
|
+
assert.equal(trace.clone.remote, control.repo)
|
|
1217
|
+
assert.equal(trace.clone.rootManifest, control.layout === 'root')
|
|
1218
|
+
assert.equal(trace.clone.nestedManifest, control.layout === 'nested')
|
|
1219
|
+
|
|
1220
|
+
trace.confirmation = {
|
|
1221
|
+
path: new URL(page.url()).pathname + new URL(page.url()).search,
|
|
1222
|
+
title: (await page.locator('body').innerText()).includes(control.title),
|
|
1223
|
+
remote: await page.locator('.warning a').getAttribute('href')
|
|
1224
|
+
}
|
|
1225
|
+
const envResponsePromise = page.waitForResponse(response => response.url() === `${baseUrl}/env` && response.request().method() === 'POST')
|
|
1226
|
+
await page.locator('#save').click()
|
|
1227
|
+
const envResponse = await envResponsePromise
|
|
1228
|
+
trace.environmentPost = envResponse.status()
|
|
1229
|
+
trace.environment = {
|
|
1230
|
+
root: await readOptional(path.resolve(workspace, 'ENVIRONMENT')),
|
|
1231
|
+
nested: await readOptional(path.resolve(workspace, 'pinokio', 'ENVIRONMENT'))
|
|
1232
|
+
}
|
|
1233
|
+
if (!envResponse.ok()) {
|
|
1234
|
+
trace.expectedReproduction = build === 'baseline' && control.layout === 'nested'
|
|
1235
|
+
trace.diagnostics = diagnostics
|
|
1236
|
+
await page.screenshot({ path: path.resolve(artifacts, 'environment-failure.png'), fullPage: true })
|
|
1237
|
+
await fsp.writeFile(path.resolve(artifacts, 'trace.json'), JSON.stringify(trace, null, 2))
|
|
1238
|
+
await fsp.writeFile(path.resolve(artifacts, 'server.log'), logs.join(''))
|
|
1239
|
+
assert.equal(trace.expectedReproduction, true, `unexpected environment POST failure: ${envResponse.status()}`)
|
|
1240
|
+
return { trace, artifacts, temporaryHome: home.root, browserVersion: browser.version() }
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
await page.waitForURL(url => url.pathname === `/p/${control.folder}`, { timeout: 30000 })
|
|
1244
|
+
await page.locator('.m.h.menu').waitFor()
|
|
1245
|
+
trace.menuBeforeInstall = await menuSnapshot(page)
|
|
1246
|
+
let status = await browserRequest(page, `/apps/status/${control.folder}`)
|
|
1247
|
+
trace.statusBeforeInstall = { status: status.status, value: stableStatus(status.json) }
|
|
1248
|
+
const installScript = `${control.layout === 'nested' ? 'pinokio/' : ''}install.js`
|
|
1249
|
+
const installLog = path.resolve(launcher, 'logs', 'api', 'install.js', 'latest')
|
|
1250
|
+
const autoStarted = await waitForRunning(page, installScript, true, 5000, control.folder)
|
|
1251
|
+
if (!autoStarted) {
|
|
1252
|
+
const installItem = trace.menuBeforeInstall.find(item => /^Install(?:\s|$)/.test(item.text))
|
|
1253
|
+
assert.ok(installItem && installItem.href, 'published app did not expose an Install action')
|
|
1254
|
+
await goto(page, baseUrl, installItem.href)
|
|
1255
|
+
}
|
|
1256
|
+
trace.install = {
|
|
1257
|
+
trigger: autoStarted ? 'environment-install-auto-start' : 'menu-click',
|
|
1258
|
+
...await waitForScriptCompletion(page, control.folder, installScript, installLog, autoStarted)
|
|
1259
|
+
}
|
|
1260
|
+
assert.equal(trace.install.completed, true, 'published install script did not complete')
|
|
1261
|
+
|
|
1262
|
+
await goto(page, baseUrl, `/p/${control.folder}`)
|
|
1263
|
+
await page.locator('.m.h.menu').waitFor()
|
|
1264
|
+
trace.menuAfterInstall = await menuSnapshot(page)
|
|
1265
|
+
status = await browserRequest(page, `/apps/status/${control.folder}`)
|
|
1266
|
+
trace.statusAfterInstall = { status: status.status, value: stableStatus(status.json) }
|
|
1267
|
+
const startItem = trace.menuAfterInstall.find(item => /^(Start|Open)/.test(item.text))
|
|
1268
|
+
assert.ok(startItem && startItem.href, 'published app did not expose a Start action after install')
|
|
1269
|
+
await goto(page, baseUrl, startItem.href)
|
|
1270
|
+
const startScript = `${control.layout === 'nested' ? 'pinokio/' : ''}start.js`
|
|
1271
|
+
trace.start = { running: await waitForRunning(page, startScript, true, 180000, control.folder) }
|
|
1272
|
+
assert.equal(trace.start.running, true, 'published start script did not enter the running state')
|
|
1273
|
+
const shellInfo = await browserRequest(page, '/info/shells')
|
|
1274
|
+
trace.start.shells = shellInfo.json
|
|
1275
|
+
? Object.values(shellInfo.json).map(shell => ({ path: shell.path, cmd: shell.cmd, done: shell.done })).filter(shell => shell.path && shell.path.includes(control.folder))
|
|
1276
|
+
: []
|
|
1277
|
+
const stop = page.locator('.stop:not(.hidden)').first()
|
|
1278
|
+
await stop.waitFor({ state: 'visible', timeout: 180000 })
|
|
1279
|
+
await stop.click()
|
|
1280
|
+
trace.start.stopped = await waitForRunning(page, startScript, false, 30000, control.folder)
|
|
1281
|
+
assert.equal(trace.start.stopped, true, 'published start script did not stop')
|
|
1282
|
+
|
|
1283
|
+
await goto(page, baseUrl, '/home')
|
|
1284
|
+
const card = page.locator(`.home-app-line[data-uri="${control.folder}"]`)
|
|
1285
|
+
await card.waitFor()
|
|
1286
|
+
trace.homeCard = {
|
|
1287
|
+
href: await card.getAttribute('data-href'),
|
|
1288
|
+
icon: await card.getAttribute('data-icon'),
|
|
1289
|
+
name: await card.getAttribute('data-name')
|
|
1290
|
+
}
|
|
1291
|
+
const icon = trace.statusAfterInstall.value && trace.statusAfterInstall.value.icon
|
|
1292
|
+
const iconResponse = icon ? await browserRequest(page, icon) : null
|
|
1293
|
+
trace.icon = { url: icon, status: iconResponse && iconResponse.status }
|
|
1294
|
+
const session = await latestSession(launcher)
|
|
1295
|
+
const report = session ? await browserRequest(page, `/apps/logs/${control.folder}/report?session=${encodeURIComponent(session)}&redaction=none`) : null
|
|
1296
|
+
trace.logs = {
|
|
1297
|
+
root: path.relative(home.pinokioHome, path.resolve(launcher, 'logs')).split(path.sep).join('/'),
|
|
1298
|
+
installLatest: fs.existsSync(installLog),
|
|
1299
|
+
session,
|
|
1300
|
+
reportStatus: report && report.status
|
|
1301
|
+
}
|
|
1302
|
+
trace.filesystem = {
|
|
1303
|
+
rootEnvironment: fs.existsSync(path.resolve(workspace, 'ENVIRONMENT')),
|
|
1304
|
+
nestedEnvironment: fs.existsSync(path.resolve(workspace, 'pinokio', 'ENVIRONMENT')),
|
|
1305
|
+
gitExclude: await readOptional(path.resolve(workspace, '.git', 'info', 'exclude')),
|
|
1306
|
+
installedMarker: fs.existsSync(path.resolve(launcher, '.installed')),
|
|
1307
|
+
helloVenv: fs.existsSync(path.resolve(workspace, 'app', '.venv'))
|
|
1308
|
+
}
|
|
1309
|
+
trace.diagnostics = diagnostics
|
|
1310
|
+
await page.screenshot({ path: path.resolve(artifacts, 'final.png'), fullPage: true })
|
|
1311
|
+
await fsp.writeFile(path.resolve(artifacts, 'trace.json'), JSON.stringify(trace, null, 2))
|
|
1312
|
+
await fsp.writeFile(path.resolve(artifacts, 'server.log'), logs.join(''))
|
|
1313
|
+
const unexpectedLocal = diagnostics.localHttpErrors.filter(entry => !(entry.status === 404 && new URL(entry.url).pathname.startsWith('/apps/logs/')))
|
|
1314
|
+
assert.deepEqual(unexpectedLocal, [], 'unexpected local 4xx/5xx during published-app flow')
|
|
1315
|
+
return { trace, artifacts, temporaryHome: home.root, browserVersion: browser.version() }
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
if (page) await page.screenshot({ path: path.resolve(artifacts, 'failure.png'), fullPage: true }).catch(() => {})
|
|
1318
|
+
await fsp.writeFile(path.resolve(artifacts, 'trace.json'), JSON.stringify(trace, null, 2)).catch(() => {})
|
|
1319
|
+
await fsp.writeFile(path.resolve(artifacts, 'server.log'), logs.join('')).catch(() => {})
|
|
1320
|
+
await fsp.writeFile(path.resolve(artifacts, 'failure.txt'), error && error.stack ? error.stack : String(error)).catch(() => {})
|
|
1321
|
+
error.artifacts = artifacts
|
|
1322
|
+
error.temporaryHome = home.root
|
|
1323
|
+
throw error
|
|
1324
|
+
} finally {
|
|
1325
|
+
if (page) await page.close().catch(() => {})
|
|
1326
|
+
if (browser) await browser.close().catch(() => {})
|
|
1327
|
+
await stopChild(child)
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function normalize(value, trace, parity = false) {
|
|
1332
|
+
if (Array.isArray(value)) return value.map(item => normalize(item, trace, parity))
|
|
1333
|
+
if (value && typeof value === 'object') {
|
|
1334
|
+
const next = {}
|
|
1335
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1336
|
+
if (['diagnostics', 'runtime_backed'].includes(key)) continue
|
|
1337
|
+
next[key] = normalize(item, trace, parity)
|
|
1338
|
+
}
|
|
1339
|
+
return next
|
|
1340
|
+
}
|
|
1341
|
+
if (typeof value !== 'string') return value
|
|
1342
|
+
let text = value.replace(/\\/g, '/')
|
|
1343
|
+
const home = trace.__home.replace(/\\/g, '/')
|
|
1344
|
+
const homes = [...new Set([home, canonicalPath(home).replace(/\\/g, '/')])].sort((a, b) => b.length - a.length)
|
|
1345
|
+
for (const candidate of homes) {
|
|
1346
|
+
text = text.replaceAll(candidate, '<PINOKIO_HOME>')
|
|
1347
|
+
const encoded = encodeURIComponent(candidate)
|
|
1348
|
+
text = text.replaceAll(encoded, '<PINOKIO_HOME_ENCODED>')
|
|
1349
|
+
text = text.replaceAll(encoded.replaceAll('%2F', '%5C'), '<PINOKIO_HOME_ENCODED>')
|
|
1350
|
+
}
|
|
1351
|
+
if (parity && trace.layout === 'nested') {
|
|
1352
|
+
text = text.replaceAll(`/api/${appId}/pinokio/`, `/api/${appId}/`)
|
|
1353
|
+
text = text.replaceAll(`/asset/api/${appId}/pinokio/`, `/asset/api/${appId}/`)
|
|
1354
|
+
text = text.replaceAll(`api/${appId}/pinokio/`, `api/${appId}/`)
|
|
1355
|
+
text = text.replaceAll(`/api/${appId}/pinokio`, `/api/${appId}`)
|
|
1356
|
+
text = text.replaceAll(`api/${appId}/pinokio`, `api/${appId}`)
|
|
1357
|
+
text = text.replace(/^pinokio\//, '')
|
|
1358
|
+
}
|
|
1359
|
+
return text
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function comparable(trace, parity = false) {
|
|
1363
|
+
const copy = JSON.parse(JSON.stringify(trace))
|
|
1364
|
+
delete copy.__home
|
|
1365
|
+
delete copy.build
|
|
1366
|
+
if (parity) {
|
|
1367
|
+
delete copy.layout
|
|
1368
|
+
delete copy.nestedChildRegression
|
|
1369
|
+
copy.ai = {
|
|
1370
|
+
status: copy.ai.status,
|
|
1371
|
+
linksOk: copy.ai.links.every(link => link.status === 200),
|
|
1372
|
+
workspace: copy.ai.links.some(link => link.body.includes('WORKSPACEAISENTINEL')),
|
|
1373
|
+
launcher: copy.ai.links.some(link => link.body.includes('LAUNCHERAISENTINEL'))
|
|
1374
|
+
}
|
|
1375
|
+
const excludeSummary = value => String(value || '').split(/\r?\n/)
|
|
1376
|
+
.map(line => line.replace(/^\/pinokio\//, '').replace(/^\//, ''))
|
|
1377
|
+
.filter(Boolean)
|
|
1378
|
+
.sort()
|
|
1379
|
+
copy.gitExclude = excludeSummary(copy.gitExclude)
|
|
1380
|
+
copy.inheritanceGitExclude = excludeSummary(copy.inheritanceGitExclude)
|
|
1381
|
+
}
|
|
1382
|
+
return normalize(copy, trace, parity)
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function comparableRuntime(trace, parity = false) {
|
|
1386
|
+
return normalize(trace.runtime_backed, trace, parity)
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function assertCurrentSemantics(trace, layout) {
|
|
1390
|
+
const prefix = layout === 'nested' ? 'pinokio/' : ''
|
|
1391
|
+
assert.equal(trace.home.status, 200)
|
|
1392
|
+
assert.equal(trace.app.status, 200)
|
|
1393
|
+
assert.equal(trace.envPage.status, 200)
|
|
1394
|
+
assert.equal(trace.envPost, 200)
|
|
1395
|
+
assert.equal(trace.startRunning, true)
|
|
1396
|
+
assert.equal(trace.menuStopWorked, true)
|
|
1397
|
+
assert.equal(trace.shortcutStartRunning, true)
|
|
1398
|
+
assert.equal(trace.shortcutStopWorked, true)
|
|
1399
|
+
assert.equal(JSON.parse(trace.shortcutAction).uri, `~/api/${appId}/${prefix}start.js`)
|
|
1400
|
+
assert.equal(trace.status.value.install_script, `${prefix}install.js`)
|
|
1401
|
+
assert.equal(trace.status.value.start_script, `${prefix}start.js`)
|
|
1402
|
+
assert.equal(trace.status.value.default_script, `${prefix}start.js`)
|
|
1403
|
+
assert.equal(trace.runCwd.replace(/\\/g, '/').endsWith(`/api/${appId}/${prefix}`.replace(/\/$/, '')), true)
|
|
1404
|
+
assert.equal(trace.env.launcher.includes('REQUIRED_VALUE=saved-through-browser'), true)
|
|
1405
|
+
assert.equal(trace.env.other, null)
|
|
1406
|
+
assert.equal(trace.envAfterActions.includes('ACTION_VALUE=set-by-action'), true)
|
|
1407
|
+
assert.equal(trace.envAfterActions.includes('SWITCH_VALUE=two'), true)
|
|
1408
|
+
assert.equal(trace.inheritance.launcher.includes('INHERITANCE_UNIQUE_SENTINEL=yes'), true)
|
|
1409
|
+
assert.equal(trace.inheritance.other, null)
|
|
1410
|
+
assert.equal(trace.iconBefore.status, 200)
|
|
1411
|
+
assert.equal(trace.iconUpload.status, 200)
|
|
1412
|
+
assert.equal(trace.uploadedIcon.launcher, true)
|
|
1413
|
+
assert.equal(trace.uploadedIcon.other, false)
|
|
1414
|
+
assert.equal(trace.iconAfter.status, 200)
|
|
1415
|
+
assert.deepEqual(trace.preSecond.items, trace.preFirst.items)
|
|
1416
|
+
assert.equal(trace.search.WORKSPACEREADMESENTINEL.files.includes('README.md'), true)
|
|
1417
|
+
assert.equal(trace.search.LAUNCHERREADMESENTINEL.files.includes(`${prefix}README.md`), true)
|
|
1418
|
+
assert.equal(trace.search.SCRIPTSEARCHSENTINEL.files.includes(`${prefix}start.js`), true)
|
|
1419
|
+
assert.equal(trace.ai.links.some(link => link.href === `/_api/${appId}/CLAUDE.md` && link.body.includes('WORKSPACEAISENTINEL')), true)
|
|
1420
|
+
assert.equal(trace.ai.links.some(link => link.href === `/_api/${appId}/${prefix}CLAUDE.md` && link.body.includes('LAUNCHERAISENTINEL')), true)
|
|
1421
|
+
assert.equal(trace.currentLog.status, 200)
|
|
1422
|
+
assert.equal(trace.currentLog.text.includes(currentSentinel), true)
|
|
1423
|
+
assert.equal(trace.logTree.status, 200)
|
|
1424
|
+
assert.equal(trace.report.status, 200)
|
|
1425
|
+
assert.equal(trace.report.hasHistorical, true)
|
|
1426
|
+
assert.equal(trace.report.hasCurrent, false)
|
|
1427
|
+
assert.equal(trace.draft.status, 200)
|
|
1428
|
+
assert.equal(trace.registryDraft.length, 1)
|
|
1429
|
+
assert.equal(trace.registryDraft[0].hasHistorical, true)
|
|
1430
|
+
assert.equal(trace.registryDraft[0].hasCurrent, false)
|
|
1431
|
+
assert.deepEqual(trace.adversarial.rootWithFolder.markers, ['Root With Folder'])
|
|
1432
|
+
assert.equal(trace.adversarial.rootWithFolder.logViewer.status, 200)
|
|
1433
|
+
assert.equal(trace.adversarial.rootWithFolder.logViewer.hasRootSentinel, true)
|
|
1434
|
+
assert.equal(trace.adversarial.rootWithFolder.logViewer.hasNestedSentinel, false)
|
|
1435
|
+
assert.equal(trace.adversarial.rootWithFolder.getHelp.status, 200)
|
|
1436
|
+
assert.equal(trace.adversarial.rootWithFolder.getHelp.hasRootSentinel, false)
|
|
1437
|
+
assert.equal(trace.adversarial.rootWithFolder.getHelp.hasNestedSentinel, true)
|
|
1438
|
+
assert.deepEqual(trace.adversarial.bothLaunchers.markers, ['Root Winner'])
|
|
1439
|
+
assert.equal(trace.adversarial.bothLaunchers.menu.some(item => item.text.includes('Root winner start')), true)
|
|
1440
|
+
assert.equal(trace.adversarial.bothLaunchers.menu.some(item => item.text.includes('Nested loser start')), false)
|
|
1441
|
+
assert.equal(trace.adversarial.brokenRoot.navigation.status, 422)
|
|
1442
|
+
assert.equal(trace.adversarial.brokenRoot.sidebarNavigation.status, 200)
|
|
1443
|
+
assert.equal(trace.adversarial.brokenRoot.sidebarMenu.some(item => item.text.includes('Fallback fs')), true)
|
|
1444
|
+
assert.equal(trace.adversarial.brokenRoot.sidebarMenu.some(item => item.text.includes('Fallback command')), true)
|
|
1445
|
+
assert.deepEqual(trace.adversarial.appNamedPinokio.markers, ['App Named Pinokio'])
|
|
1446
|
+
if (trace.runtime_backed.status === 'EXECUTED') {
|
|
1447
|
+
assert.equal(trace.runtime_backed.run_string.clicked, true)
|
|
1448
|
+
assert.equal(canonicalPath(trace.runtime_backed.run_string.output), canonicalPath(homePath(trace, `${appId}/${prefix}`)))
|
|
1449
|
+
assert.equal(trace.runtime_backed.script_navigation.status, 200)
|
|
1450
|
+
assert.equal(trace.runtime_backed.terminal_has_sentinel, true)
|
|
1451
|
+
assert.equal(trace.runtime_backed.result.sentinel, runtimeSentinel)
|
|
1452
|
+
assert.equal(canonicalPath(trace.runtime_backed.result.cwd), canonicalPath(homePath(trace, `${appId}/${prefix}`)))
|
|
1453
|
+
assert.equal(canonicalPath(trace.runtime_backed.result.relative), canonicalPath(homePath(trace, `${appId}/${prefix}something`)))
|
|
1454
|
+
assert.equal(trace.runtime_backed.log.status, 200)
|
|
1455
|
+
assert.equal(trace.runtime_backed.log.has_sentinel, true)
|
|
1456
|
+
}
|
|
1457
|
+
if (layout === 'nested') {
|
|
1458
|
+
assert.match(trace.gitExclude, /^\/pinokio\/ENVIRONMENT$/m)
|
|
1459
|
+
assert.deepEqual(trace.nestedChildRegression, {
|
|
1460
|
+
startHref: `/api/${nestedChildId}/pinokio/pinokio/tool.js`,
|
|
1461
|
+
stopUri: `~/api/${nestedChildId}/pinokio/pinokio/tool.js`,
|
|
1462
|
+
candidates: ['pinokio/pinokio/tool.js'],
|
|
1463
|
+
started: true,
|
|
1464
|
+
stopped: true
|
|
1465
|
+
})
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function homePath(trace, relative) {
|
|
1470
|
+
return path.resolve(trace.__home, 'api', relative)
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function canonicalPath(value) {
|
|
1474
|
+
let current = path.resolve(value)
|
|
1475
|
+
const suffix = []
|
|
1476
|
+
while (!fs.existsSync(current)) {
|
|
1477
|
+
const parent = path.dirname(current)
|
|
1478
|
+
assert.notEqual(parent, current, `cannot canonicalize ${value}`)
|
|
1479
|
+
suffix.unshift(path.basename(current))
|
|
1480
|
+
current = parent
|
|
1481
|
+
}
|
|
1482
|
+
return path.resolve(fs.realpathSync.native(current), ...suffix)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function stableNestedCompatibility(trace) {
|
|
1486
|
+
return {
|
|
1487
|
+
home: trace.home.status,
|
|
1488
|
+
app: trace.app.status,
|
|
1489
|
+
envPage: trace.envPage.status,
|
|
1490
|
+
inheritanceSentinel: !!(trace.inheritance.launcher && trace.inheritance.launcher.includes('INHERITANCE_UNIQUE_SENTINEL=yes')),
|
|
1491
|
+
reportStatus: trace.report.status,
|
|
1492
|
+
reportHistorical: trace.report.hasHistorical,
|
|
1493
|
+
reportCurrent: trace.report.hasCurrent,
|
|
1494
|
+
draftStatus: trace.draft.status,
|
|
1495
|
+
draftHistorical: trace.registryDraft[0] && trace.registryDraft[0].hasHistorical,
|
|
1496
|
+
nestedChild: trace.nestedChildRegression && {
|
|
1497
|
+
startHref: trace.nestedChildRegression.startHref,
|
|
1498
|
+
candidates: trace.nestedChildRegression.candidates,
|
|
1499
|
+
started: trace.nestedChildRegression.started
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function stableLiveRoot(trace) {
|
|
1505
|
+
const menu = items => (items || []).map(item => ({ text: item.text, href: item.href, run: item.run, cwd: item.cwd, action: item.action }))
|
|
1506
|
+
const status = response => response && ({
|
|
1507
|
+
...response,
|
|
1508
|
+
value: response.value && {
|
|
1509
|
+
...response.value,
|
|
1510
|
+
default_target: response.value.default_target
|
|
1511
|
+
? path.relative(trace.__home, response.value.default_target).split(path.sep).join('/')
|
|
1512
|
+
: response.value.default_target
|
|
1513
|
+
}
|
|
1514
|
+
})
|
|
1515
|
+
return {
|
|
1516
|
+
explore: trace.explore,
|
|
1517
|
+
downloadName: trace.downloadName,
|
|
1518
|
+
clone: trace.clone,
|
|
1519
|
+
confirmation: trace.confirmation,
|
|
1520
|
+
environmentPost: trace.environmentPost,
|
|
1521
|
+
environment: {
|
|
1522
|
+
root: trace.environment && trace.environment.root !== null,
|
|
1523
|
+
nested: trace.environment && trace.environment.nested !== null
|
|
1524
|
+
},
|
|
1525
|
+
menuBeforeInstall: menu(trace.menuBeforeInstall),
|
|
1526
|
+
menuAfterInstall: menu(trace.menuAfterInstall),
|
|
1527
|
+
statusBeforeInstall: status(trace.statusBeforeInstall),
|
|
1528
|
+
statusAfterInstall: status(trace.statusAfterInstall),
|
|
1529
|
+
install: trace.install,
|
|
1530
|
+
start: trace.start && { running: trace.start.running, stopped: trace.start.stopped },
|
|
1531
|
+
homeCard: trace.homeCard,
|
|
1532
|
+
icon: trace.icon,
|
|
1533
|
+
logs: trace.logs && { root: trace.logs.root, installLatest: trace.logs.installLatest, reportStatus: trace.logs.reportStatus },
|
|
1534
|
+
filesystem: trace.filesystem
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function assertLiveNested(trace) {
|
|
1539
|
+
assert.equal(trace.expectedReproduction, undefined)
|
|
1540
|
+
assert.equal(trace.environmentPost, 200)
|
|
1541
|
+
assert.equal(trace.environment.root, null)
|
|
1542
|
+
assert.ok(trace.environment.nested)
|
|
1543
|
+
assert.equal(trace.statusAfterInstall.status, 200)
|
|
1544
|
+
assert.equal(trace.statusAfterInstall.value.install_script, 'pinokio/install.js')
|
|
1545
|
+
assert.equal(trace.statusAfterInstall.value.start_script, 'pinokio/start.js')
|
|
1546
|
+
assert.equal(trace.install.completed, true)
|
|
1547
|
+
assert.equal(trace.start.running, true)
|
|
1548
|
+
assert.equal(trace.start.stopped, true)
|
|
1549
|
+
assert.equal(trace.icon.status, 200)
|
|
1550
|
+
assert.equal(trace.logs.root, `api/${liveControls.nested.folder}/pinokio/logs`)
|
|
1551
|
+
assert.equal(trace.logs.installLatest, true)
|
|
1552
|
+
assert.equal(trace.logs.reportStatus, 200)
|
|
1553
|
+
assert.equal(trace.filesystem.rootEnvironment, false)
|
|
1554
|
+
assert.equal(trace.filesystem.nestedEnvironment, true)
|
|
1555
|
+
assert.equal(trace.filesystem.installedMarker, true)
|
|
1556
|
+
assert.match(trace.filesystem.gitExclude, /^\/pinokio\/ENVIRONMENT$/m)
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function materializeCheckout(value, tempRoot) {
|
|
1560
|
+
const candidate = path.resolve(String(value || ''))
|
|
1561
|
+
if (value && fs.existsSync(path.resolve(candidate, 'package.json'))) return { path: candidate, cleanup: async () => {} }
|
|
1562
|
+
const ref = value && value !== true ? String(value) : 'HEAD'
|
|
1563
|
+
const checkout = path.resolve(tempRoot, 'baseline-checkout')
|
|
1564
|
+
run('git', ['worktree', 'add', '--detach', checkout, ref], { cwd: repoRoot })
|
|
1565
|
+
return {
|
|
1566
|
+
path: checkout,
|
|
1567
|
+
cleanup: async () => {
|
|
1568
|
+
try { run('git', ['worktree', 'remove', '--force', checkout], { cwd: repoRoot }) } catch (_) {}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function checkoutIdentity(checkout) {
|
|
1574
|
+
const revision = run('git', ['rev-parse', 'HEAD'], { cwd: checkout })
|
|
1575
|
+
const status = run('git', ['status', '--short'], { cwd: checkout })
|
|
1576
|
+
const diff = run('git', ['diff', '--binary', 'HEAD'], { cwd: checkout })
|
|
1577
|
+
const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], { cwd: checkout }).split(/\r?\n/).filter(Boolean)
|
|
1578
|
+
const digest = crypto.createHash('sha256').update(`${revision}\n${status}\n${diff}`)
|
|
1579
|
+
for (const relative of untracked) {
|
|
1580
|
+
digest.update(`\nuntracked:${relative}\n`)
|
|
1581
|
+
digest.update(fs.readFileSync(path.resolve(checkout, relative)))
|
|
1582
|
+
}
|
|
1583
|
+
return { checkout, revision, dirty: !!status, status, untracked, diff_hash: digest.digest('hex') }
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
async function parentMode() {
|
|
1587
|
+
const args = parseArgs()
|
|
1588
|
+
const playwright = loadPlaywright()
|
|
1589
|
+
const executablePath = browserExecutable(playwright)
|
|
1590
|
+
assert.ok(executablePath, 'No supported Chromium or Brave executable is available')
|
|
1591
|
+
const started = new Date().toISOString().replace(/[:.]/g, '-')
|
|
1592
|
+
const artifactsRoot = path.resolve(outputRoot, started)
|
|
1593
|
+
const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'pinokio-nested-checkouts-'))
|
|
1594
|
+
await fsp.mkdir(artifactsRoot, { recursive: true })
|
|
1595
|
+
const baseline = await materializeCheckout(args.baseline || args['baseline-ref'] || 'HEAD', tempRoot)
|
|
1596
|
+
const current = path.resolve(String(args.current || repoRoot))
|
|
1597
|
+
const runtimeActive = args['runtime-active'] || process.env.PINOKIO_ACCEPTANCE_RUNTIME
|
|
1598
|
+
const runtimeSnapshot = args['runtime-snapshot'] || process.env.PINOKIO_ACCEPTANCE_RUNTIME_SNAPSHOT
|
|
1599
|
+
assert.equal(!!runtimeActive, !!runtimeSnapshot, 'runtime-active and runtime-snapshot must be supplied together')
|
|
1600
|
+
const runtimeManager = runtimeActive ? await prepareRuntime({
|
|
1601
|
+
active: runtimeActive,
|
|
1602
|
+
snapshot: runtimeSnapshot,
|
|
1603
|
+
createSnapshot: !!args['create-runtime-snapshot']
|
|
1604
|
+
}) : null
|
|
1605
|
+
const runtimeManifestHash = runtimeManager
|
|
1606
|
+
? crypto.createHash('sha256').update(JSON.stringify(runtimeManager.manifest)).digest('hex')
|
|
1607
|
+
: null
|
|
1608
|
+
const evidence = {
|
|
1609
|
+
started_at: new Date().toISOString(),
|
|
1610
|
+
platform: `${process.platform} ${process.arch}`,
|
|
1611
|
+
node: process.version,
|
|
1612
|
+
browser: null,
|
|
1613
|
+
baseline: checkoutIdentity(baseline.path),
|
|
1614
|
+
current: checkoutIdentity(current),
|
|
1615
|
+
runtime_backed: runtimeManager ? {
|
|
1616
|
+
status: 'RUNNING',
|
|
1617
|
+
active: runtimeManager.activeRoot,
|
|
1618
|
+
snapshot: runtimeManager.snapshotRoot,
|
|
1619
|
+
snapshot_entries: runtimeManager.manifest.length,
|
|
1620
|
+
snapshot_manifest_sha256: runtimeManifestHash
|
|
1621
|
+
} : 'UNVERIFIED: PINOKIO_ACCEPTANCE_RUNTIME was not supplied; this execution intentionally runs the runtime-free subset only.',
|
|
1622
|
+
unverified: {
|
|
1623
|
+
runtime_backed_steps: runtimeManager ? null : 'Steps 4, 16, and 23 require a test-owned runtime.',
|
|
1624
|
+
tier6_live_explore: args.tier6 || args['tier6-only'] ? null : 'The published-app Explore installation matrix is a separate release test.',
|
|
1625
|
+
windows: process.platform === 'win32' ? null : 'This run does not provide Windows execution evidence.'
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
evidence.browser = { executable: executablePath, version: null }
|
|
1629
|
+
const cases = args['tier6-only'] ? [] : [
|
|
1630
|
+
{ build: 'baseline', checkout: baseline.path, layout: 'root' },
|
|
1631
|
+
{ build: 'baseline', checkout: baseline.path, layout: 'nested' },
|
|
1632
|
+
{ build: 'current', checkout: current, layout: 'root' },
|
|
1633
|
+
{ build: 'current', checkout: current, layout: 'nested' }
|
|
1634
|
+
]
|
|
1635
|
+
const results = {}
|
|
1636
|
+
let failed = false
|
|
1637
|
+
try {
|
|
1638
|
+
for (const item of cases) {
|
|
1639
|
+
const key = `${item.build}-${item.layout}`
|
|
1640
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ state: 'running', case: key })}\n`)
|
|
1641
|
+
try {
|
|
1642
|
+
const runtime = runtimeManager ? await runtimeManager.restore() : null
|
|
1643
|
+
const result = await executeCase({ ...item, playwright, executablePath, artifactsRoot, runtime })
|
|
1644
|
+
result.trace.__home = result.trace.layout === item.layout ? result.temporaryHome.replace(/\\/g, '/') + '/pinokio-home' : ''
|
|
1645
|
+
results[key] = result
|
|
1646
|
+
evidence.browser.version ||= result.browserVersion
|
|
1647
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ state: 'collected', case: key, artifacts: result.artifacts })}\n`)
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
failed = true
|
|
1650
|
+
results[key] = { error: error && error.stack ? error.stack : String(error), artifacts: error.artifacts || null, temporaryHome: error.temporaryHome || null }
|
|
1651
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ state: 'failed', case: key, error: results[key].error, artifacts: results[key].artifacts })}\n`)
|
|
1652
|
+
break
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
if (!failed && cases.length > 0) {
|
|
1657
|
+
const baselineRoot = results['baseline-root'].trace
|
|
1658
|
+
const baselineNested = results['baseline-nested'].trace
|
|
1659
|
+
const currentRoot = results['current-root'].trace
|
|
1660
|
+
const currentNested = results['current-nested'].trace
|
|
1661
|
+
assertCurrentSemantics(currentRoot, 'root')
|
|
1662
|
+
assertCurrentSemantics(currentNested, 'nested')
|
|
1663
|
+
assert.deepEqual(comparable(currentRoot), comparable(baselineRoot), 'strict additivity failed: baseline root and current root traces differ')
|
|
1664
|
+
assert.deepEqual(stableNestedCompatibility(currentNested), stableNestedCompatibility(baselineNested), 'existing nested behavior regressed')
|
|
1665
|
+
assert.deepEqual(comparable(currentNested, true), comparable(currentRoot, true), 'layout parity failed after translating the nested launcher root')
|
|
1666
|
+
if (runtimeManager) {
|
|
1667
|
+
assert.deepEqual(comparableRuntime(currentRoot), comparableRuntime(baselineRoot), 'runtime-backed strict additivity failed for the root layout')
|
|
1668
|
+
assert.deepEqual(comparableRuntime(currentNested, true), comparableRuntime(currentRoot, true), 'runtime-backed root/nested parity failed')
|
|
1669
|
+
}
|
|
1670
|
+
evidence.comparisons = {
|
|
1671
|
+
strict_additivity: 'PASS',
|
|
1672
|
+
existing_nested_behavior: 'PASS',
|
|
1673
|
+
current_layout_parity: 'PASS',
|
|
1674
|
+
tier4_adversarial: 'PASS',
|
|
1675
|
+
runtime_backed: runtimeManager ? 'PASS' : 'UNVERIFIED'
|
|
1676
|
+
}
|
|
1677
|
+
if (runtimeManager) evidence.runtime_backed.status = 'PASS'
|
|
1678
|
+
}
|
|
1679
|
+
if (!failed && (args.tier6 || args['tier6-only'])) {
|
|
1680
|
+
assert.ok(runtimeManager, 'Tier 6 requires runtime-active and runtime-snapshot')
|
|
1681
|
+
const liveCases = [
|
|
1682
|
+
{ build: 'baseline', checkout: baseline.path, controlName: 'root', control: liveControls.root },
|
|
1683
|
+
{ build: 'baseline', checkout: baseline.path, controlName: 'nested', control: liveControls.nested },
|
|
1684
|
+
{ build: 'current', checkout: current, controlName: 'root', control: liveControls.root },
|
|
1685
|
+
{ build: 'current', checkout: current, controlName: 'nested', control: liveControls.nested }
|
|
1686
|
+
]
|
|
1687
|
+
for (const item of liveCases) {
|
|
1688
|
+
const key = `live-${item.build}-${item.controlName}`
|
|
1689
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ state: 'running', case: key })}\n`)
|
|
1690
|
+
try {
|
|
1691
|
+
const runtime = await runtimeManager.restore()
|
|
1692
|
+
const result = await executeLiveCase({ ...item, playwright, executablePath, artifactsRoot, runtime })
|
|
1693
|
+
result.trace.__home = `${result.temporaryHome.replace(/\\/g, '/')}/pinokio-home`
|
|
1694
|
+
results[key] = result
|
|
1695
|
+
evidence.browser.version ||= result.browserVersion
|
|
1696
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ state: 'collected', case: key, artifacts: result.artifacts })}\n`)
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
failed = true
|
|
1699
|
+
results[key] = { error: error && error.stack ? error.stack : String(error), artifacts: error.artifacts || null, temporaryHome: error.temporaryHome || null }
|
|
1700
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ state: 'failed', case: key, error: results[key].error, artifacts: results[key].artifacts })}\n`)
|
|
1701
|
+
break
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (!failed) {
|
|
1705
|
+
const baselineLiveRoot = results['live-baseline-root'].trace
|
|
1706
|
+
const baselineLiveNested = results['live-baseline-nested'].trace
|
|
1707
|
+
const currentLiveRoot = results['live-current-root'].trace
|
|
1708
|
+
const currentLiveNested = results['live-current-nested'].trace
|
|
1709
|
+
assert.equal(baselineLiveRoot.clone.commit, currentLiveRoot.clone.commit, 'root control changed commits between baseline and current')
|
|
1710
|
+
assert.equal(baselineLiveNested.clone.commit, currentLiveNested.clone.commit, 'nested control changed commits between baseline and current')
|
|
1711
|
+
assert.deepEqual(stableLiveRoot(currentLiveRoot), stableLiveRoot(baselineLiveRoot), 'published root-layout behavior changed')
|
|
1712
|
+
assert.equal(baselineLiveNested.expectedReproduction, true, 'baseline did not reproduce the published nested environment failure')
|
|
1713
|
+
assertLiveNested(currentLiveNested)
|
|
1714
|
+
evidence.comparisons ||= {}
|
|
1715
|
+
evidence.comparisons.tier6_live_root_additivity = 'PASS'
|
|
1716
|
+
evidence.comparisons.tier6_live_nested_reproduction_and_repair = 'PASS'
|
|
1717
|
+
evidence.runtime_backed.status = 'PASS'
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
} catch (error) {
|
|
1721
|
+
failed = true
|
|
1722
|
+
evidence.failure = error && error.stack ? error.stack : String(error)
|
|
1723
|
+
} finally {
|
|
1724
|
+
if (runtimeManager) {
|
|
1725
|
+
try {
|
|
1726
|
+
await runtimeManager.verifySnapshot()
|
|
1727
|
+
evidence.runtime_backed.snapshot_unchanged = true
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
failed = true
|
|
1730
|
+
evidence.runtime_backed.snapshot_unchanged = false
|
|
1731
|
+
evidence.runtime_backed.snapshot_error = error && error.stack ? error.stack : String(error)
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
evidence.finished_at = new Date().toISOString()
|
|
1735
|
+
evidence.results = Object.fromEntries(Object.entries(results).map(([key, value]) => [key, {
|
|
1736
|
+
artifacts: value.artifacts || null,
|
|
1737
|
+
error: value.error || null,
|
|
1738
|
+
server: value.server || null
|
|
1739
|
+
}]))
|
|
1740
|
+
await fsp.writeFile(path.resolve(artifactsRoot, 'evidence.json'), JSON.stringify(evidence, null, 2))
|
|
1741
|
+
for (const value of Object.values(results)) {
|
|
1742
|
+
if (value.temporaryHome) await fsp.rm(value.temporaryHome, { recursive: true, force: true }).catch(() => {})
|
|
1743
|
+
}
|
|
1744
|
+
await baseline.cleanup()
|
|
1745
|
+
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
|
1746
|
+
}
|
|
1747
|
+
process.stdout.write(`${resultPrefix}${JSON.stringify({ ok: !failed, artifacts: artifactsRoot, comparisons: evidence.comparisons || null, failure: evidence.failure || null })}\n`)
|
|
1748
|
+
if (failed) process.exitCode = 1
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (process.argv.includes('--server')) {
|
|
1752
|
+
runServerMode().catch(error => {
|
|
1753
|
+
console.error(error && error.stack ? error.stack : error)
|
|
1754
|
+
process.exit(1)
|
|
1755
|
+
})
|
|
1756
|
+
} else {
|
|
1757
|
+
parentMode().catch(error => {
|
|
1758
|
+
console.error(error && error.stack ? error.stack : error)
|
|
1759
|
+
process.exit(1)
|
|
1760
|
+
})
|
|
1761
|
+
}
|