pinokiod 7.3.14 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kernel/api/index.js +111 -15
- package/kernel/api/script/index.js +10 -0
- package/kernel/autolaunch.js +111 -0
- package/kernel/environment.js +89 -1
- package/kernel/git.js +9 -19
- package/kernel/index.js +142 -43
- package/kernel/launch_requirements.js +1115 -0
- package/kernel/ready.js +231 -0
- package/kernel/script.js +16 -0
- package/kernel/shells.js +9 -1
- package/kernel/workspace_status.js +111 -45
- package/package.json +1 -1
- package/server/autolaunch.js +725 -0
- package/server/index.js +244 -411
- package/server/public/logs.js +15 -1
- package/server/public/style.css +99 -31
- package/server/views/app.ejs +263 -159
- package/server/views/autolaunch.ejs +363 -75
- package/server/views/index.ejs +550 -26
- package/server/views/logs.ejs +14 -12
- package/server/views/partials/app_autolaunch_dependency_events.ejs +99 -0
- package/server/views/partials/app_autolaunch_dependency_save.ejs +10 -0
- package/server/views/partials/app_autolaunch_dependency_styles.ejs +357 -0
- package/server/views/partials/app_autolaunch_modal_helpers.ejs +347 -0
- package/server/views/partials/autolaunch_dependency_helpers.ejs +204 -0
- package/server/views/partials/autolaunch_dependency_save.ejs +13 -0
- package/server/views/partials/autolaunch_dependency_styles.ejs +347 -0
- package/server/views/partials/home_action_modal.ejs +4 -1
- package/server/views/partials/launch_requirements_status_client.ejs +271 -0
- package/server/views/partials/launch_requirements_status_styles.ejs +171 -0
- package/server/views/partials/launch_settings_dependency_save_factory.ejs +45 -0
- package/server/views/partials/launch_settings_dependency_script_loader_factory.ejs +25 -0
- package/server/views/terminal.ejs +196 -2
- package/test/home-autolaunch-live-ui.test.js +455 -0
- package/test/launch-requirements-browser.test.js +579 -0
- package/test/launch-requirements-contract-coverage.test.js +627 -0
- package/test/launch-requirements-real-browser.js +625 -0
- package/test/launch-requirements-status-client.test.js +132 -0
- package/test/launch-requirements.test.js +1806 -0
- package/test/launch-settings-ui.test.js +370 -0
- package/test/ready-state.test.js +49 -0
- package/test/server-autolaunch.test.js +1052 -0
- package/test/startup-git-index-benchmark.js +409 -0
- package/test/startup-git-index-browser.js +320 -0
- package/test/startup-git-index-performance.test.js +380 -0
- package/test/startup-git-index-refactor.test.js +450 -0
- package/test/startup-git-index-route.test.js +588 -0
- package/test/universal-launcher.smoke.spec.js +10 -9
- package/test/workspace-gitignore-benchmark.js +815 -0
- package/test/workspace-gitignore-path-scoped.test.js +256 -0
- package/spec/INSTRUCTION_SYNC.md +0 -432
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const childProcess = require('node:child_process')
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const fsp = require('node:fs/promises')
|
|
7
|
+
const net = require('node:net')
|
|
8
|
+
const os = require('node:os')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(__dirname, '..')
|
|
12
|
+
const outputRoot = path.resolve(repoRoot, 'output/playwright/startup-git-index')
|
|
13
|
+
const readyPrefix = 'PINOKIO_STARTUP_BROWSER_READY '
|
|
14
|
+
const serverMode = process.argv.includes('--server')
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
17
|
+
const args = {}
|
|
18
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
19
|
+
const item = argv[i]
|
|
20
|
+
if (!item.startsWith('--')) continue
|
|
21
|
+
const key = item.slice(2)
|
|
22
|
+
const next = argv[i + 1]
|
|
23
|
+
if (next && !next.startsWith('--')) {
|
|
24
|
+
args[key] = next
|
|
25
|
+
i += 1
|
|
26
|
+
} else {
|
|
27
|
+
args[key] = true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return args
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadPlaywright() {
|
|
34
|
+
try {
|
|
35
|
+
return require('playwright')
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const bin = childProcess.execFileSync('which', ['playwright'], { encoding: 'utf8' }).trim()
|
|
40
|
+
if (bin) {
|
|
41
|
+
return require(path.join(path.dirname(path.dirname(bin)), 'playwright'))
|
|
42
|
+
}
|
|
43
|
+
} catch (_) {}
|
|
44
|
+
|
|
45
|
+
throw new Error('Playwright is required. Run: npx -y -p playwright node test/startup-git-index-browser.js')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function chromiumExecutablePath(playwright) {
|
|
49
|
+
const candidates = [
|
|
50
|
+
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
|
51
|
+
playwright.chromium.executablePath(),
|
|
52
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
53
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
54
|
+
].filter(Boolean)
|
|
55
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function run(cmd, args, cwd, options = {}) {
|
|
59
|
+
return childProcess.execFileSync(cmd, args, {
|
|
60
|
+
cwd,
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
GIT_AUTHOR_NAME: 'Codex Browser Test',
|
|
65
|
+
GIT_AUTHOR_EMAIL: 'codex-browser-test@example.com',
|
|
66
|
+
GIT_COMMITTER_NAME: 'Codex Browser Test',
|
|
67
|
+
GIT_COMMITTER_EMAIL: 'codex-browser-test@example.com',
|
|
68
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
69
|
+
...options.env,
|
|
70
|
+
},
|
|
71
|
+
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
}).trim()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function git(args, cwd) {
|
|
76
|
+
return run('git', args, cwd)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function initAppRepo(repoPath, { remote }) {
|
|
80
|
+
fs.mkdirSync(repoPath, { recursive: true })
|
|
81
|
+
git(['init'], repoPath)
|
|
82
|
+
git(['config', 'user.name', 'Codex Browser Test'], repoPath)
|
|
83
|
+
git(['config', 'user.email', 'codex-browser-test@example.com'], repoPath)
|
|
84
|
+
fs.writeFileSync(path.join(repoPath, 'pinokio.js'), [
|
|
85
|
+
'module.exports = {',
|
|
86
|
+
' title: "Startup Browser Fixture",',
|
|
87
|
+
' description: "Fixture for startup git index Browser verification.",',
|
|
88
|
+
' menu: []',
|
|
89
|
+
'}',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n'))
|
|
92
|
+
fs.writeFileSync(path.join(repoPath, 'tracked.txt'), 'top clean\n')
|
|
93
|
+
git(['add', '.'], repoPath)
|
|
94
|
+
git(['commit', '-m', 'top baseline'], repoPath)
|
|
95
|
+
git(['branch', '-M', 'main'], repoPath)
|
|
96
|
+
git(['remote', 'add', 'origin', remote], repoPath)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function initNestedRepo(repoPath, { remote }) {
|
|
100
|
+
fs.mkdirSync(repoPath, { recursive: true })
|
|
101
|
+
git(['init'], repoPath)
|
|
102
|
+
git(['config', 'user.name', 'Codex Browser Test'], repoPath)
|
|
103
|
+
git(['config', 'user.email', 'codex-browser-test@example.com'], repoPath)
|
|
104
|
+
fs.writeFileSync(path.join(repoPath, 'nested.txt'), 'nested clean\n')
|
|
105
|
+
git(['add', '.'], repoPath)
|
|
106
|
+
git(['commit', '-m', 'nested baseline'], repoPath)
|
|
107
|
+
git(['branch', '-M', 'main'], repoPath)
|
|
108
|
+
git(['remote', 'add', 'origin', remote], repoPath)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function freePort() {
|
|
112
|
+
return await new Promise((resolve, reject) => {
|
|
113
|
+
const server = net.createServer()
|
|
114
|
+
server.listen(0, '127.0.0.1', () => {
|
|
115
|
+
const port = server.address().port
|
|
116
|
+
server.close(() => resolve(port))
|
|
117
|
+
})
|
|
118
|
+
server.on('error', reject)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function createHome() {
|
|
123
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'pinokio-startup-browser-'))
|
|
124
|
+
const fakeUserHome = path.join(root, 'user')
|
|
125
|
+
const pinokioHome = path.join(root, 'pinokio')
|
|
126
|
+
const apiRoot = path.join(pinokioHome, 'api')
|
|
127
|
+
const workspaceName = 'browser-fixture'
|
|
128
|
+
const workspace = path.join(apiRoot, workspaceName)
|
|
129
|
+
const nested = path.join(workspace, 'nested')
|
|
130
|
+
await fsp.mkdir(fakeUserHome, { recursive: true })
|
|
131
|
+
await fsp.mkdir(apiRoot, { recursive: true })
|
|
132
|
+
const runtimeBin = path.resolve(process.env.PINOKIO_REAL_BROWSER_BIN || path.join(os.homedir(), 'pinokio', 'bin'))
|
|
133
|
+
if (fs.existsSync(runtimeBin)) {
|
|
134
|
+
await fsp.symlink(runtimeBin, path.join(pinokioHome, 'bin'), 'dir')
|
|
135
|
+
}
|
|
136
|
+
initAppRepo(workspace, { remote: 'https://example.com/pinokio-browser-fixture.git' })
|
|
137
|
+
initNestedRepo(nested, { remote: 'https://example.com/pinokio-browser-fixture-nested.git' })
|
|
138
|
+
return { root, fakeUserHome, pinokioHome, workspaceName, workspace, nested }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runServerMode() {
|
|
142
|
+
const args = parseArgs()
|
|
143
|
+
const port = Number(args.port)
|
|
144
|
+
const home = path.resolve(String(args.home || ''))
|
|
145
|
+
const pkg = require(path.join(repoRoot, 'package.json'))
|
|
146
|
+
const Server = require(path.join(repoRoot, 'server'))
|
|
147
|
+
const server = new Server({
|
|
148
|
+
store: { store: { home, version: pkg.version } },
|
|
149
|
+
agent: 'test',
|
|
150
|
+
newsfeed: '',
|
|
151
|
+
portal: '',
|
|
152
|
+
})
|
|
153
|
+
server.port = port
|
|
154
|
+
await server.start({ debug: true })
|
|
155
|
+
process.stdout.write(`${readyPrefix}${JSON.stringify({ port, home, pid: process.pid })}\n`)
|
|
156
|
+
setInterval(() => {}, 1000)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function waitForReady(child, timeoutMs = 60000) {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
let stdout = ''
|
|
162
|
+
let stderr = ''
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
reject(new Error(`server did not become ready in ${timeoutMs}ms\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
165
|
+
}, timeoutMs)
|
|
166
|
+
child.stdout.on('data', (chunk) => {
|
|
167
|
+
const text = chunk.toString()
|
|
168
|
+
stdout += text
|
|
169
|
+
for (const line of text.split(/\r?\n/)) {
|
|
170
|
+
if (line.startsWith(readyPrefix)) {
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
resolve({ ready: JSON.parse(line.slice(readyPrefix.length)), stdout, stderr })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
child.stderr.on('data', (chunk) => {
|
|
177
|
+
stderr += chunk.toString()
|
|
178
|
+
})
|
|
179
|
+
child.on('exit', (code, signal) => {
|
|
180
|
+
clearTimeout(timer)
|
|
181
|
+
reject(new Error(`server exited before ready: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function withBrowserServer(callback) {
|
|
187
|
+
const home = await createHome()
|
|
188
|
+
const port = await freePort()
|
|
189
|
+
const artifacts = path.join(outputRoot, `${Date.now()}-changes-ui`)
|
|
190
|
+
await fsp.mkdir(artifacts, { recursive: true })
|
|
191
|
+
const child = childProcess.spawn(process.execPath, [
|
|
192
|
+
__filename,
|
|
193
|
+
'--server',
|
|
194
|
+
'--port',
|
|
195
|
+
String(port),
|
|
196
|
+
'--home',
|
|
197
|
+
home.pinokioHome,
|
|
198
|
+
], {
|
|
199
|
+
cwd: repoRoot,
|
|
200
|
+
env: {
|
|
201
|
+
...process.env,
|
|
202
|
+
HOME: home.fakeUserHome,
|
|
203
|
+
PINOKIO_HOME: home.pinokioHome,
|
|
204
|
+
},
|
|
205
|
+
detached: true,
|
|
206
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
207
|
+
})
|
|
208
|
+
const logs = []
|
|
209
|
+
child.stdout.on('data', (chunk) => logs.push(chunk.toString()))
|
|
210
|
+
child.stderr.on('data', (chunk) => logs.push(chunk.toString()))
|
|
211
|
+
|
|
212
|
+
const playwright = loadPlaywright()
|
|
213
|
+
const executablePath = chromiumExecutablePath(playwright)
|
|
214
|
+
assert.ok(executablePath, 'No Chromium executable is available for Browser verification')
|
|
215
|
+
let browser
|
|
216
|
+
let page
|
|
217
|
+
try {
|
|
218
|
+
await waitForReady(child)
|
|
219
|
+
browser = await playwright.chromium.launch({ headless: true, executablePath })
|
|
220
|
+
page = await browser.newPage()
|
|
221
|
+
page.setDefaultTimeout(20000)
|
|
222
|
+
const pageErrors = []
|
|
223
|
+
page.on('pageerror', (error) => pageErrors.push(error.message || String(error)))
|
|
224
|
+
await callback({
|
|
225
|
+
...home,
|
|
226
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
227
|
+
artifacts,
|
|
228
|
+
page,
|
|
229
|
+
})
|
|
230
|
+
assert.deepEqual(pageErrors, [])
|
|
231
|
+
await fsp.writeFile(path.join(artifacts, 'server.log'), logs.join(''))
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (page) {
|
|
234
|
+
await page.screenshot({ path: path.join(artifacts, 'failure.png'), fullPage: true }).catch(() => {})
|
|
235
|
+
await fsp.writeFile(path.join(artifacts, 'body.txt'), await page.locator('body').innerText().catch(() => '')).catch(() => {})
|
|
236
|
+
}
|
|
237
|
+
await fsp.writeFile(path.join(artifacts, 'server.log'), logs.join('')).catch(() => {})
|
|
238
|
+
error.message = `${error.message}\nartifacts: ${artifacts}`
|
|
239
|
+
throw error
|
|
240
|
+
} finally {
|
|
241
|
+
if (browser) {
|
|
242
|
+
await browser.close().catch(() => {})
|
|
243
|
+
}
|
|
244
|
+
if (child.pid) {
|
|
245
|
+
try {
|
|
246
|
+
process.kill(-child.pid, 'SIGTERM')
|
|
247
|
+
} catch (_) {
|
|
248
|
+
child.kill('SIGTERM')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
await new Promise((resolve) => {
|
|
252
|
+
const timer = setTimeout(resolve, 3000)
|
|
253
|
+
child.once('exit', () => {
|
|
254
|
+
clearTimeout(timer)
|
|
255
|
+
resolve()
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
await fsp.rm(home.root, { recursive: true, force: true })
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function main() {
|
|
263
|
+
await withBrowserServer(async ({ page, baseUrl, workspaceName, workspace, nested, artifacts }) => {
|
|
264
|
+
await page.goto(`${baseUrl}/p/${workspaceName}/files`, { waitUntil: 'domcontentloaded' })
|
|
265
|
+
await page.locator('#fs-status').waitFor({ state: 'visible' })
|
|
266
|
+
await page.screenshot({ path: path.join(artifacts, 'initial.png'), fullPage: true })
|
|
267
|
+
|
|
268
|
+
await fsp.writeFile(path.join(workspace, 'tracked.txt'), 'top clean\ntop dirty\n')
|
|
269
|
+
await fsp.writeFile(path.join(nested, 'nested.txt'), 'nested clean\nnested dirty\n')
|
|
270
|
+
|
|
271
|
+
await page.locator('#fs-changes-btn').click()
|
|
272
|
+
await page.locator('#fs-changes-btn .badge').waitFor({ state: 'visible' })
|
|
273
|
+
await page.waitForFunction(() => {
|
|
274
|
+
const badge = document.querySelector('#fs-changes-btn .badge')
|
|
275
|
+
return badge && badge.textContent && badge.textContent.trim() === '2'
|
|
276
|
+
}, null, { timeout: 15000 })
|
|
277
|
+
|
|
278
|
+
const topRepoItem = page.locator(`#fs-changes-menu .git-changes-item[data-repo="${workspaceName}"]`)
|
|
279
|
+
const nestedRepoItem = page.locator(`#fs-changes-menu .git-changes-item[data-repo="${workspaceName}/nested"]`)
|
|
280
|
+
await topRepoItem.waitFor()
|
|
281
|
+
await nestedRepoItem.waitFor()
|
|
282
|
+
await page.screenshot({ path: path.join(artifacts, 'changes-expanded.png'), fullPage: true })
|
|
283
|
+
|
|
284
|
+
await topRepoItem.click()
|
|
285
|
+
await page.locator('.pinokio-diff-modal').waitFor({ state: 'visible' })
|
|
286
|
+
await page.locator('.pinokio-git-diff-file-item-row[data-filepath="tracked.txt"]').click()
|
|
287
|
+
await page.locator('.pinokio-git-diff-viewer-panel').getByText('top dirty').waitFor()
|
|
288
|
+
await page.screenshot({ path: path.join(artifacts, 'top-diff.png'), fullPage: true })
|
|
289
|
+
|
|
290
|
+
const closeButton = page.locator('.swal2-close')
|
|
291
|
+
if (await closeButton.count()) {
|
|
292
|
+
await closeButton.click()
|
|
293
|
+
} else {
|
|
294
|
+
await page.keyboard.press('Escape')
|
|
295
|
+
}
|
|
296
|
+
await page.locator('.pinokio-diff-modal').waitFor({ state: 'hidden' }).catch(() => {})
|
|
297
|
+
await page.locator('#fs-changes-btn').click()
|
|
298
|
+
const nestedRepoItemAfterModal = page.locator(`#fs-changes-menu .git-changes-item[data-repo="${workspaceName}/nested"]`)
|
|
299
|
+
await nestedRepoItemAfterModal.waitFor({ state: 'visible' })
|
|
300
|
+
await nestedRepoItemAfterModal.click()
|
|
301
|
+
await page.locator('.pinokio-diff-modal').waitFor({ state: 'visible' })
|
|
302
|
+
await page.locator('.pinokio-git-diff-file-item-row[data-filepath="nested.txt"]').click()
|
|
303
|
+
await page.locator('.pinokio-git-diff-viewer-panel').getByText('nested dirty').waitFor()
|
|
304
|
+
await page.screenshot({ path: path.join(artifacts, 'nested-diff.png'), fullPage: true })
|
|
305
|
+
|
|
306
|
+
console.log(JSON.stringify({ ok: true, artifacts }))
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (serverMode) {
|
|
311
|
+
runServerMode().catch((error) => {
|
|
312
|
+
console.error(error && error.stack ? error.stack : error)
|
|
313
|
+
process.exitCode = 1
|
|
314
|
+
})
|
|
315
|
+
} else {
|
|
316
|
+
main().catch((error) => {
|
|
317
|
+
console.error(error && error.stack ? error.stack : error)
|
|
318
|
+
process.exitCode = 1
|
|
319
|
+
})
|
|
320
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const childProcess = require('node:child_process')
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const fsp = require('node:fs/promises')
|
|
7
|
+
const net = require('node:net')
|
|
8
|
+
const os = require('node:os')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
const test = require('node:test')
|
|
11
|
+
|
|
12
|
+
const repoRoot = path.resolve(__dirname, '..')
|
|
13
|
+
const readyPrefix = 'PINOKIO_STARTUP_PERF_READY '
|
|
14
|
+
const serverMode = process.argv.includes('--server')
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
17
|
+
const args = {}
|
|
18
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
19
|
+
const item = argv[i]
|
|
20
|
+
if (!item.startsWith('--')) continue
|
|
21
|
+
const key = item.slice(2)
|
|
22
|
+
const next = argv[i + 1]
|
|
23
|
+
if (next && !next.startsWith('--')) {
|
|
24
|
+
args[key] = next
|
|
25
|
+
i += 1
|
|
26
|
+
} else {
|
|
27
|
+
args[key] = true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return args
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(cmd, args, cwd, options = {}) {
|
|
34
|
+
return childProcess.execFileSync(cmd, args, {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
GIT_AUTHOR_NAME: 'Codex Perf Test',
|
|
40
|
+
GIT_AUTHOR_EMAIL: 'codex-perf-test@example.com',
|
|
41
|
+
GIT_COMMITTER_NAME: 'Codex Perf Test',
|
|
42
|
+
GIT_COMMITTER_EMAIL: 'codex-perf-test@example.com',
|
|
43
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
44
|
+
...options.env,
|
|
45
|
+
},
|
|
46
|
+
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
|
47
|
+
}).trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function git(args, cwd) {
|
|
51
|
+
return run('git', args, cwd)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function initRepo(repoPath, { remote, file, content, message }) {
|
|
55
|
+
fs.mkdirSync(repoPath, { recursive: true })
|
|
56
|
+
git(['init'], repoPath)
|
|
57
|
+
git(['config', 'user.name', 'Codex Perf Test'], repoPath)
|
|
58
|
+
git(['config', 'user.email', 'codex-perf-test@example.com'], repoPath)
|
|
59
|
+
fs.writeFileSync(path.join(repoPath, file), content)
|
|
60
|
+
git(['add', '.'], repoPath)
|
|
61
|
+
git(['commit', '-m', message], repoPath)
|
|
62
|
+
git(['branch', '-M', 'main'], repoPath)
|
|
63
|
+
git(['remote', 'add', 'origin', remote], repoPath)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function freePort() {
|
|
67
|
+
return await new Promise((resolve, reject) => {
|
|
68
|
+
const server = net.createServer()
|
|
69
|
+
server.listen(0, '127.0.0.1', () => {
|
|
70
|
+
const port = server.address().port
|
|
71
|
+
server.close(() => resolve(port))
|
|
72
|
+
})
|
|
73
|
+
server.on('error', reject)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function createFixtureHome() {
|
|
78
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'pinokio-startup-perf-'))
|
|
79
|
+
const fakeUserHome = path.join(root, 'user')
|
|
80
|
+
const pinokioHome = path.join(root, 'pinokio')
|
|
81
|
+
const apiRoot = path.join(pinokioHome, 'api')
|
|
82
|
+
const workspaceName = 'perf-fixture'
|
|
83
|
+
const workspace = path.join(apiRoot, workspaceName)
|
|
84
|
+
const nested = path.join(workspace, 'nested')
|
|
85
|
+
await fsp.mkdir(fakeUserHome, { recursive: true })
|
|
86
|
+
await fsp.mkdir(apiRoot, { recursive: true })
|
|
87
|
+
initRepo(workspace, {
|
|
88
|
+
remote: 'https://example.com/pinokio-perf-fixture.git',
|
|
89
|
+
file: 'tracked.txt',
|
|
90
|
+
content: 'top clean\n',
|
|
91
|
+
message: 'top baseline',
|
|
92
|
+
})
|
|
93
|
+
fs.writeFileSync(path.join(workspace, '.gitignore'), 'tracked-hidden.txt\n')
|
|
94
|
+
fs.writeFileSync(path.join(workspace, 'tracked-hidden.txt'), 'hidden clean\n')
|
|
95
|
+
git(['add', '.gitignore'], workspace)
|
|
96
|
+
git(['add', '-f', 'tracked-hidden.txt'], workspace)
|
|
97
|
+
git(['commit', '-m', 'hidden baseline'], workspace)
|
|
98
|
+
initRepo(nested, {
|
|
99
|
+
remote: 'https://example.com/pinokio-perf-fixture-nested.git',
|
|
100
|
+
file: 'nested.txt',
|
|
101
|
+
content: 'nested clean\n',
|
|
102
|
+
message: 'nested baseline',
|
|
103
|
+
})
|
|
104
|
+
return { root, fakeUserHome, pinokioHome, apiRoot, workspaceName, workspace, nested }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createMetrics(home) {
|
|
108
|
+
const apiRoot = path.resolve(home, 'api')
|
|
109
|
+
const empty = () => ({
|
|
110
|
+
startedAt: Date.now(),
|
|
111
|
+
gitIndexCalls: 0,
|
|
112
|
+
gitReposCalls: 0,
|
|
113
|
+
apiRootReposCalls: 0,
|
|
114
|
+
workspaceReposCalls: 0,
|
|
115
|
+
rootWatcherCalls: 0,
|
|
116
|
+
workspaceWatcherCalls: 0,
|
|
117
|
+
gitIgnorePreScanCalls: 0,
|
|
118
|
+
fs: {
|
|
119
|
+
readdir: 0,
|
|
120
|
+
readFile: 0,
|
|
121
|
+
stat: 0,
|
|
122
|
+
lstat: 0,
|
|
123
|
+
access: 0,
|
|
124
|
+
readdirEntries: 0,
|
|
125
|
+
readFileBytes: 0,
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
let current = empty()
|
|
129
|
+
return {
|
|
130
|
+
apiRoot,
|
|
131
|
+
reset() {
|
|
132
|
+
current = empty()
|
|
133
|
+
},
|
|
134
|
+
snapshot() {
|
|
135
|
+
return JSON.parse(JSON.stringify(current))
|
|
136
|
+
},
|
|
137
|
+
inc(key, amount = 1) {
|
|
138
|
+
current[key] += amount
|
|
139
|
+
},
|
|
140
|
+
incFs(key, amount = 1) {
|
|
141
|
+
current.fs[key] += amount
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function installInstrumentation(home) {
|
|
147
|
+
const metrics = createMetrics(home)
|
|
148
|
+
const fsMethods = ['readdir', 'readFile', 'stat', 'lstat', 'access']
|
|
149
|
+
const originals = {}
|
|
150
|
+
for (const method of fsMethods) {
|
|
151
|
+
originals[method] = fs.promises[method]
|
|
152
|
+
fs.promises[method] = async function instrumentedFsMethod(...args) {
|
|
153
|
+
const result = await originals[method].apply(this, args)
|
|
154
|
+
metrics.incFs(method)
|
|
155
|
+
if (method === 'readdir' && Array.isArray(result)) {
|
|
156
|
+
metrics.incFs('readdirEntries', result.length)
|
|
157
|
+
} else if (method === 'readFile') {
|
|
158
|
+
metrics.incFs('readFileBytes', Buffer.isBuffer(result) ? result.length : Buffer.byteLength(String(result)))
|
|
159
|
+
}
|
|
160
|
+
return result
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const Git = require(path.join(repoRoot, 'kernel/git'))
|
|
165
|
+
const WorkspaceStatusManager = require(path.join(repoRoot, 'kernel/workspace_status'))
|
|
166
|
+
const originalIndex = Git.prototype.index
|
|
167
|
+
const originalRepos = Git.prototype.repos
|
|
168
|
+
const originalEnsureWatcher = WorkspaceStatusManager.prototype.ensureWatcher
|
|
169
|
+
const originalEnsureGitIgnoreEngine = WorkspaceStatusManager.prototype.ensureGitIgnoreEngine
|
|
170
|
+
|
|
171
|
+
Git.prototype.index = async function instrumentedGitIndex(...args) {
|
|
172
|
+
metrics.inc('gitIndexCalls')
|
|
173
|
+
return originalIndex.apply(this, args)
|
|
174
|
+
}
|
|
175
|
+
Git.prototype.repos = async function instrumentedGitRepos(root, ...args) {
|
|
176
|
+
metrics.inc('gitReposCalls')
|
|
177
|
+
const resolvedRoot = root ? path.resolve(root) : ''
|
|
178
|
+
if (resolvedRoot === metrics.apiRoot) {
|
|
179
|
+
metrics.inc('apiRootReposCalls')
|
|
180
|
+
} else if (resolvedRoot.startsWith(`${metrics.apiRoot}${path.sep}`)) {
|
|
181
|
+
metrics.inc('workspaceReposCalls')
|
|
182
|
+
}
|
|
183
|
+
return originalRepos.call(this, root, ...args)
|
|
184
|
+
}
|
|
185
|
+
WorkspaceStatusManager.prototype.ensureWatcher = async function instrumentedEnsureWatcher(workspaceName, workspaceRoot, ...args) {
|
|
186
|
+
const resolvedRoot = workspaceRoot ? path.resolve(workspaceRoot) : ''
|
|
187
|
+
if (workspaceName === 'api' && resolvedRoot === metrics.apiRoot) {
|
|
188
|
+
metrics.inc('rootWatcherCalls')
|
|
189
|
+
} else {
|
|
190
|
+
metrics.inc('workspaceWatcherCalls')
|
|
191
|
+
}
|
|
192
|
+
return originalEnsureWatcher.call(this, workspaceName, workspaceRoot, ...args)
|
|
193
|
+
}
|
|
194
|
+
WorkspaceStatusManager.prototype.ensureGitIgnoreEngine = async function instrumentedEnsureGitIgnoreEngine(...args) {
|
|
195
|
+
metrics.inc('gitIgnorePreScanCalls')
|
|
196
|
+
return originalEnsureGitIgnoreEngine.apply(this, args)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return metrics
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function runServerMode() {
|
|
203
|
+
const args = parseArgs()
|
|
204
|
+
const port = Number(args.port)
|
|
205
|
+
const home = path.resolve(String(args.home || ''))
|
|
206
|
+
const metrics = installInstrumentation(home)
|
|
207
|
+
const pkg = require(path.join(repoRoot, 'package.json'))
|
|
208
|
+
const Server = require(path.join(repoRoot, 'server'))
|
|
209
|
+
const server = new Server({
|
|
210
|
+
store: { store: { home, version: pkg.version } },
|
|
211
|
+
agent: 'test',
|
|
212
|
+
newsfeed: '',
|
|
213
|
+
portal: '',
|
|
214
|
+
})
|
|
215
|
+
server.port = port
|
|
216
|
+
await server.start({ debug: true })
|
|
217
|
+
server.app.get('/__startup-git-index-test/metrics', (req, res) => {
|
|
218
|
+
res.json({ ok: true, metrics: metrics.snapshot() })
|
|
219
|
+
})
|
|
220
|
+
server.app.post('/__startup-git-index-test/reset', (req, res) => {
|
|
221
|
+
metrics.reset()
|
|
222
|
+
res.json({ ok: true })
|
|
223
|
+
})
|
|
224
|
+
process.stdout.write(`${readyPrefix}${JSON.stringify({ port, home, pid: process.pid })}\n`)
|
|
225
|
+
setInterval(() => {}, 1000)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function waitForReady(child, timeoutMs = 60000) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
let stdout = ''
|
|
231
|
+
let stderr = ''
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
reject(new Error(`server did not become ready in ${timeoutMs}ms\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
234
|
+
}, timeoutMs)
|
|
235
|
+
child.stdout.on('data', (chunk) => {
|
|
236
|
+
const text = chunk.toString()
|
|
237
|
+
stdout += text
|
|
238
|
+
for (const line of text.split(/\r?\n/)) {
|
|
239
|
+
if (line.startsWith(readyPrefix)) {
|
|
240
|
+
clearTimeout(timer)
|
|
241
|
+
resolve({ ready: JSON.parse(line.slice(readyPrefix.length)), stdout, stderr })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
child.stderr.on('data', (chunk) => {
|
|
246
|
+
stderr += chunk.toString()
|
|
247
|
+
})
|
|
248
|
+
child.on('exit', (code, signal) => {
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
reject(new Error(`server exited before ready: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function fetchJson(baseUrl, route, options = {}) {
|
|
256
|
+
const response = await fetch(`${baseUrl}${route}`, {
|
|
257
|
+
method: options.method || 'GET',
|
|
258
|
+
headers: options.body ? { 'content-type': 'application/json' } : undefined,
|
|
259
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
260
|
+
})
|
|
261
|
+
const text = await response.text()
|
|
262
|
+
let json = null
|
|
263
|
+
try {
|
|
264
|
+
json = text ? JSON.parse(text) : null
|
|
265
|
+
} catch (_) {}
|
|
266
|
+
assert.ok(response.ok, `${route} failed with ${response.status}: ${text}`)
|
|
267
|
+
return json
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function sleep(ms) {
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function withPerfServer(callback, options = {}) {
|
|
275
|
+
const fixture = await createFixtureHome()
|
|
276
|
+
const port = await freePort()
|
|
277
|
+
const env = {
|
|
278
|
+
...process.env,
|
|
279
|
+
HOME: fixture.fakeUserHome,
|
|
280
|
+
PINOKIO_HOME: fixture.pinokioHome,
|
|
281
|
+
}
|
|
282
|
+
if (options.disableWatch) {
|
|
283
|
+
env.PINOKIO_DISABLE_WATCH = '1'
|
|
284
|
+
}
|
|
285
|
+
const child = childProcess.spawn(process.execPath, [
|
|
286
|
+
__filename,
|
|
287
|
+
'--server',
|
|
288
|
+
'--port',
|
|
289
|
+
String(port),
|
|
290
|
+
'--home',
|
|
291
|
+
fixture.pinokioHome,
|
|
292
|
+
], {
|
|
293
|
+
cwd: repoRoot,
|
|
294
|
+
env,
|
|
295
|
+
detached: true,
|
|
296
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
297
|
+
})
|
|
298
|
+
const logs = []
|
|
299
|
+
child.stdout.on('data', (chunk) => logs.push(chunk.toString()))
|
|
300
|
+
child.stderr.on('data', (chunk) => logs.push(chunk.toString()))
|
|
301
|
+
try {
|
|
302
|
+
await waitForReady(child)
|
|
303
|
+
await callback({
|
|
304
|
+
...fixture,
|
|
305
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
306
|
+
})
|
|
307
|
+
} catch (error) {
|
|
308
|
+
error.message = `${error.message}\nserver log:\n${logs.join('')}`
|
|
309
|
+
throw error
|
|
310
|
+
} finally {
|
|
311
|
+
if (child.pid) {
|
|
312
|
+
try {
|
|
313
|
+
process.kill(-child.pid, 'SIGTERM')
|
|
314
|
+
} catch (_) {
|
|
315
|
+
child.kill('SIGTERM')
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
await new Promise((resolve) => {
|
|
319
|
+
const timer = setTimeout(resolve, 3000)
|
|
320
|
+
child.once('exit', () => {
|
|
321
|
+
clearTimeout(timer)
|
|
322
|
+
resolve()
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
await fsp.rm(fixture.root, { recursive: true, force: true })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!serverMode) test('startup path does not run global git index or root API watcher pre-scan', async () => {
|
|
330
|
+
await withPerfServer(async ({ baseUrl }) => {
|
|
331
|
+
await sleep(5000)
|
|
332
|
+
const { metrics } = await fetchJson(baseUrl, '/__startup-git-index-test/metrics')
|
|
333
|
+
assert.equal(metrics.gitIndexCalls, 0, 'startup must not call kernel.git.index()')
|
|
334
|
+
assert.equal(metrics.rootWatcherCalls, 0, 'startup must not call root ensureWatcher("api")')
|
|
335
|
+
assert.equal(metrics.apiRootReposCalls, 0, 'startup must not recursively scan the API root with kernel.git.repos(apiRoot)')
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
if (!serverMode) test('/info/gitstatus stays selected-workspace scoped and avoids whole API scans', async () => {
|
|
340
|
+
await withPerfServer(async ({ baseUrl, workspaceName, workspace }) => {
|
|
341
|
+
await sleep(5000)
|
|
342
|
+
await fetchJson(baseUrl, '/__startup-git-index-test/reset', { method: 'POST', body: {} })
|
|
343
|
+
await fsp.writeFile(path.join(workspace, 'tracked.txt'), 'top clean\ntop dirty\n')
|
|
344
|
+
await fsp.writeFile(path.join(workspace, 'tracked-hidden.txt'), 'hidden clean\nhidden dirty\n')
|
|
345
|
+
const status = await fetchJson(baseUrl, `/info/gitstatus/${workspaceName}`)
|
|
346
|
+
assert.equal(status.totalChanges, 1)
|
|
347
|
+
assert.equal(status.repos.length, 2)
|
|
348
|
+
assert.equal(
|
|
349
|
+
status.repos.some((repo) => Array.isArray(repo.changes) && repo.changes.some((change) => change.file === 'tracked-hidden.txt')),
|
|
350
|
+
false,
|
|
351
|
+
'tracked paths hidden by .gitignore should not appear in /info/gitstatus'
|
|
352
|
+
)
|
|
353
|
+
const { metrics } = await fetchJson(baseUrl, '/__startup-git-index-test/metrics')
|
|
354
|
+
assert.equal(metrics.gitIgnorePreScanCalls, 0, '/info/gitstatus must not call recursive ensureGitIgnoreEngine()')
|
|
355
|
+
assert.equal(metrics.apiRootReposCalls, 0, '/info/gitstatus must not call kernel.git.repos(apiRoot)')
|
|
356
|
+
assert.ok(metrics.workspaceReposCalls >= 1, '/info/gitstatus should scan the selected workspace')
|
|
357
|
+
assert.ok(metrics.fs.readdir < 10000, `/info/gitstatus should stay below whole-tree scale; readdir=${metrics.fs.readdir}`)
|
|
358
|
+
}, { disableWatch: true })
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
if (!serverMode) test('/info/gitstatus first watched request avoids recursive gitignore pre-scan', async () => {
|
|
362
|
+
await withPerfServer(async ({ baseUrl, workspaceName, workspace }) => {
|
|
363
|
+
await sleep(5000)
|
|
364
|
+
await fetchJson(baseUrl, '/__startup-git-index-test/reset', { method: 'POST', body: {} })
|
|
365
|
+
await fsp.writeFile(path.join(workspace, 'tracked.txt'), 'top clean\ntop dirty\n')
|
|
366
|
+
await fsp.writeFile(path.join(workspace, 'tracked-hidden.txt'), 'hidden clean\nhidden dirty\n')
|
|
367
|
+
const status = await fetchJson(baseUrl, `/info/gitstatus/${workspaceName}`)
|
|
368
|
+
assert.equal(status.totalChanges, 1)
|
|
369
|
+
const { metrics } = await fetchJson(baseUrl, '/__startup-git-index-test/metrics')
|
|
370
|
+
assert.equal(metrics.gitIgnorePreScanCalls, 0, 'first watched /info/gitstatus request must not call recursive ensureGitIgnoreEngine()')
|
|
371
|
+
assert.ok(metrics.workspaceWatcherCalls >= 1, '/info/gitstatus should still initialize the selected workspace watcher')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
if (serverMode) {
|
|
376
|
+
runServerMode().catch((error) => {
|
|
377
|
+
console.error(error && error.stack ? error.stack : error)
|
|
378
|
+
process.exitCode = 1
|
|
379
|
+
})
|
|
380
|
+
}
|