perf: parse, chunk and hash documents in a worker thread and add startup and document-parse benchmarks
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
|
||||
/**
|
||||
* Bundles the document parse worker for tests. The bundle lives under
|
||||
* node_modules/.cache so the external pdfjs-dist (ESM, loaded lazily by the
|
||||
* parser) resolves from the repository's node_modules.
|
||||
*/
|
||||
export async function buildDocumentParseWorker(): Promise<{ workerPath: string; dispose: () => Promise<void> }> {
|
||||
const directory = resolve('node_modules/.cache', `goodbuddy-document-parse-worker-${randomUUID()}`)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const workerPath = join(directory, 'document-parse-worker.cjs')
|
||||
await build({
|
||||
entryPoints: [resolve('src/main/document-parse-worker.ts')],
|
||||
outfile: workerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
logLevel: 'silent',
|
||||
external: ['pdfjs-dist', 'electron']
|
||||
})
|
||||
return {
|
||||
workerPath,
|
||||
dispose: () => rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
}
|
||||
}
|
||||
|
||||
function escapePdfText(value: string): string {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)')
|
||||
}
|
||||
|
||||
/** A text-layer PDF with one content stream per page; each page is a list of lines. */
|
||||
export function createTextPdf(pages: readonly (readonly string[])[]): Buffer {
|
||||
const pageCount = pages.length
|
||||
const objects: string[] = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
`<< /Type /Pages /Kids [${pages.map((_, index) => `${4 + index * 2} 0 R`).join(' ')}] /Count ${pageCount} >>`,
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
|
||||
]
|
||||
for (const lines of pages) {
|
||||
const pageObject = objects.length + 1
|
||||
const stream = [
|
||||
'BT /F1 10 Tf 12 TL 40 760 Td',
|
||||
...lines.map((line, index) => `${index === 0 ? '' : 'T* '}(${escapePdfText(line)}) Tj`),
|
||||
'ET'
|
||||
].join('\n')
|
||||
objects.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${pageObject + 1} 0 R >>`,
|
||||
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`
|
||||
)
|
||||
}
|
||||
let content = '%PDF-1.4\n'
|
||||
const offsets: number[] = []
|
||||
for (const [index, object] of objects.entries()) {
|
||||
offsets.push(Buffer.byteLength(content, 'latin1'))
|
||||
content += `${index + 1} 0 obj\n${object}\nendobj\n`
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(content, 'latin1')
|
||||
content += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
|
||||
content += offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')
|
||||
content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
|
||||
return Buffer.from(content, 'latin1')
|
||||
}
|
||||
|
||||
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review'.split(' ')
|
||||
|
||||
/** Deterministic pseudo-random sentence generator. */
|
||||
export function sentenceGenerator(seed = 42): (count: number) => string {
|
||||
let state = seed
|
||||
const random = (): number => ((state = (state * 1664525 + 1013904223) >>> 0) / 4294967296)
|
||||
return (count) => Array.from({ length: count }, () => words[Math.floor(random() * words.length)]).join(' ')
|
||||
}
|
||||
|
||||
export function createLargePdf(pageCount: number, linesPerPage: number): Buffer {
|
||||
const sentence = sentenceGenerator(7)
|
||||
return createTextPdf(Array.from({ length: pageCount }, (_, page) =>
|
||||
Array.from({ length: linesPerPage }, (_, line) => `Page ${page + 1} line ${line + 1}: ${sentence(10)}.`)))
|
||||
}
|
||||
|
||||
export function createDocx(paragraphs: readonly string[], tables: readonly (readonly (readonly string[])[])[] = []): Buffer {
|
||||
const body = [
|
||||
...paragraphs.map((text) => `<w:p><w:r><w:t>${text}</w:t></w:r><w:r><w:tab/><w:t>tab</w:t></w:r></w:p>`),
|
||||
...tables.map((rows) => `<w:tbl>${rows.map((row) => `<w:tr>${row.map((cell) => `<w:tc><w:p><w:t>${cell}</w:t></w:p></w:tc>`).join('')}</w:tr>`).join('')}</w:tbl>`),
|
||||
'<w:p><w:t>tail & <end> 中文</w:t></w:p>'
|
||||
].join('')
|
||||
return Buffer.from(zipSync({
|
||||
'[Content_Types].xml': strToU8('<Types/>'),
|
||||
'word/document.xml': strToU8(`<w:document><w:body>${body}</w:body></w:document>`)
|
||||
}))
|
||||
}
|
||||
|
||||
export function createXlsx(sheets: readonly (readonly (readonly string[])[])[]): Buffer {
|
||||
const shared: string[] = []
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
sheets.forEach((rows, sheetIndex) => {
|
||||
const xmlRows = rows.map((row, rowIndex) => `<row r="${rowIndex + 1}">${row.map((cell, cellIndex) => {
|
||||
if (/^\d+$/u.test(cell)) return `<c r="${String.fromCharCode(65 + cellIndex)}${rowIndex + 1}"><v>${cell}</v></c>`
|
||||
if (cellIndex === 2) return `<c t="inlineStr"><is><t>${cell}</t></is></c>`
|
||||
shared.push(cell)
|
||||
return `<c t="s"><v>${shared.length - 1}</v></c>`
|
||||
}).join('')}</row>`).join('')
|
||||
files[`xl/worksheets/sheet${sheetIndex + 1}.xml`] = strToU8(`<worksheet><sheetData>${xmlRows}</sheetData></worksheet>`)
|
||||
})
|
||||
files['xl/sharedStrings.xml'] = strToU8(`<sst>${shared.map((value) => `<si><t>${value}</t></si>`).join('')}</sst>`)
|
||||
return Buffer.from(zipSync(files))
|
||||
}
|
||||
|
||||
export function createPptx(slides: readonly string[], withImage = false): Buffer {
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
if (withImage) {
|
||||
files['ppt/media/image1.png'] = new Uint8Array(Array.from({ length: 4096 }, (_, index) => index % 251))
|
||||
files['ppt/media/image2.jpg'] = new Uint8Array([255, 216, 255, 224, 1, 2, 3])
|
||||
}
|
||||
slides.forEach((text, index) => {
|
||||
const page = index + 1
|
||||
files[`ppt/slides/slide${page}.xml`] = strToU8(
|
||||
`<p:sld><a:p><a:t>${text}</a:t></a:p>${withImage ? `<p:pic><a:blip r:embed="rId${(index % 2) + 1}"/></p:pic>` : ''}</p:sld>`
|
||||
)
|
||||
if (withImage) {
|
||||
files[`ppt/slides/_rels/slide${page}.xml.rels`] = strToU8(
|
||||
'<Relationships><Relationship Id="rId1" Target="../media/image1.png"/><Relationship Id="rId2" Target="../media/image2.jpg"/></Relationships>'
|
||||
)
|
||||
}
|
||||
})
|
||||
return Buffer.from(zipSync(files))
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Cold-start measurement driver (PERF-01). Launched by
|
||||
// build/run-startup-perf.cjs, once per measured launch. Runs inside the
|
||||
// Electron main process: isolates the profile, blocks every non-loopback
|
||||
// request, imports the current production Main and records when startup
|
||||
// milestones happen. All times are epoch milliseconds (timeOrigin + now) so the
|
||||
// supervisor can relate them to its spawn time. The report holds only phase
|
||||
// names, times and counts; never conversation text or file paths.
|
||||
//
|
||||
// GB_STARTUP_MODE=prepare builds a reusable profile instead of measuring:
|
||||
// acknowledges the release notes, disables the startup update check, seeds
|
||||
// GB_STARTUP_SEED synthetic conversations, then quits through the normal
|
||||
// shutdown path so the databases are checkpointed and closed.
|
||||
import { app, BrowserWindow, session } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
import { clearTimeout, setTimeout } from 'node:timers'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { pathToFileURL, URL } from 'node:url'
|
||||
|
||||
const now = () => performance.timeOrigin + performance.now()
|
||||
const driverStart = now()
|
||||
const directory = process.env.GB_STARTUP_DIR
|
||||
const resultPath = process.env.GB_STARTUP_RESULT
|
||||
const root = process.env.GB_PERF_ROOT
|
||||
if (!directory || !resultPath || !root) throw new Error('Run through build/run-startup-perf.cjs')
|
||||
const mode = process.env.GB_STARTUP_MODE === 'prepare' ? 'prepare' : 'measure'
|
||||
const profilePath = process.env.GB_STARTUP_CPU_PROFILE
|
||||
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
||||
|
||||
// Same isolation as tests/support/app-perf-driver.mjs; it must happen before
|
||||
// the production Main module loads (single-instance lock, storage paths).
|
||||
app.setAppPath(root)
|
||||
app.setName(packageJson.name)
|
||||
app.setVersion(packageJson.version)
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.setPath('sessionData', join(directory, 'session'))
|
||||
app.setAppLogsPath(join(directory, 'logs'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
|
||||
|
||||
const report = { mode, status: 'running', times: { driverStart }, blockedRequests: { fetch: [], renderer: [] }, renderer: {}, flags: {} }
|
||||
const times = report.times
|
||||
const mark = name => { if (times[name] === undefined) times[name] = now() }
|
||||
const writeReport = () => writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
const mainDelay = monitorEventLoopDelay({ resolution: 5 })
|
||||
mainDelay.enable()
|
||||
|
||||
const deadline = setTimeout(() => {
|
||||
report.status = 'failed'
|
||||
report.error = 'Driver timeout'
|
||||
writeReport()
|
||||
app.exit(1)
|
||||
}, Number(process.env.GB_STARTUP_DRIVER_TIMEOUT_MS || 120_000))
|
||||
|
||||
// No external requests: the fake model and everything else must be loopback.
|
||||
const isLoopback = url => {
|
||||
try {
|
||||
const { hostname, protocol } = new URL(url)
|
||||
if (!['http:', 'https:', 'ws:', 'wss:'].includes(protocol)) return true
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
|
||||
} catch { return true }
|
||||
}
|
||||
const hostOf = url => { try { return new URL(url).hostname } catch { return 'invalid' } }
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url
|
||||
if (url && !isLoopback(url)) {
|
||||
report.blockedRequests.fetch.push(hostOf(url))
|
||||
return Promise.reject(new TypeError('blocked by startup benchmark'))
|
||||
}
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
let profiler
|
||||
if (profilePath) {
|
||||
const inspector = await import('node:inspector')
|
||||
profiler = new inspector.Session()
|
||||
profiler.connect()
|
||||
const post = (method, params) => new Promise((resolve, reject) => profiler.post(method, params, (error, result) => error ? reject(error) : resolve(result)))
|
||||
profiler.postAsync = post
|
||||
await post('Profiler.enable')
|
||||
await post('Profiler.setSamplingInterval', { interval: 250 })
|
||||
await post('Profiler.start')
|
||||
}
|
||||
|
||||
app.once('ready', () => mark('appReady'))
|
||||
// Registered before Main imports, so it runs before Main's whenReady handler.
|
||||
void app.whenReady().then(() => {
|
||||
session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, (details, callback) => {
|
||||
if (isLoopback(details.url)) { callback({}); return }
|
||||
report.blockedRequests.renderer.push(hostOf(details.url))
|
||||
callback({ cancel: true })
|
||||
})
|
||||
})
|
||||
|
||||
let win
|
||||
app.on('browser-window-created', (_event, created) => {
|
||||
if (win) return
|
||||
win = created
|
||||
mark('windowCreated')
|
||||
created.once('ready-to-show', () => mark('readyToShow'))
|
||||
created.once('show', () => mark('windowShown'))
|
||||
created.webContents.once('did-start-loading', () => mark('didStartLoading'))
|
||||
created.webContents.once('dom-ready', () => { mark('domReady'); void installRendererProbe() })
|
||||
created.webContents.once('did-finish-load', () => mark('didFinishLoad'))
|
||||
created.webContents.on('render-process-gone', (_e, details) => { report.error = `render-process-gone ${details.reason}` })
|
||||
})
|
||||
|
||||
mark('mainImportStart')
|
||||
await import(pathToFileURL(join(root, 'out', 'main', 'index.js')).href)
|
||||
mark('mainImportEnd')
|
||||
|
||||
const js = code => win.webContents.executeJavaScript(code, true)
|
||||
|
||||
// Composer is interactive when it exists, is enabled, laid out, and not inside
|
||||
// an inert or hidden subtree (a modal dialog makes the app shell inert).
|
||||
const composerSelector = '.composer__input textarea'
|
||||
const expectedConversations = mode === 'measure' ? Math.max(0, Number(process.env.GB_STARTUP_EXPECT_CONVERSATIONS || 0)) : 0
|
||||
const rendererProbe = `new Promise(resolve => {
|
||||
const origin = performance.timeOrigin;
|
||||
const at = () => origin + performance.now();
|
||||
const r = { installedAt: at(), visibleAtInstall: document.visibilityState === 'visible' };
|
||||
// Chromium does not report first-contentful-paint when the first paint
|
||||
// happens while the page is hidden, and the main window is created hidden
|
||||
// until ready-to-show. "First visible frame" is the second animation frame
|
||||
// after the page becomes visible, i.e. a frame actually presented on screen.
|
||||
let frameDone = false;
|
||||
const firstVisibleFrame = () => {
|
||||
r.visibleAt = at();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { r.firstVisibleFrame = at(); frameDone = true; if (check()) finish(); }));
|
||||
};
|
||||
if (document.visibilityState === 'visible') firstVisibleFrame();
|
||||
else document.addEventListener('visibilitychange', function onVisible() { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onVisible); firstVisibleFrame(); });
|
||||
const check = () => {
|
||||
const c = document.querySelector(${JSON.stringify(composerSelector)});
|
||||
if (c && r.composerPresent === undefined) r.composerPresent = at();
|
||||
if (!r.firstConversationRow && document.querySelector('button.conversation-item')) r.firstConversationRow = at();
|
||||
if (!r.startupDialog && document.querySelector('section.release-notes-dialog')) r.startupDialog = at();
|
||||
if (c && r.composerInteractive === undefined && !c.disabled && c.getClientRects().length > 0 && !c.closest('[inert], [hidden], [aria-hidden="true"]')) r.composerInteractive = at();
|
||||
// Seeded profiles: the sidebar is populated once the virtualized list
|
||||
// reports the seeded total (or mounts enough rows to fill the viewport).
|
||||
if (${expectedConversations} > 0 && r.sidebarPopulated === undefined) {
|
||||
const rows = document.querySelectorAll('button.conversation-item').length;
|
||||
const entry = document.querySelector('.conversation-list [aria-setsize]');
|
||||
const total = entry ? Number(entry.getAttribute('aria-setsize')) : rows;
|
||||
if (total >= ${expectedConversations} && rows >= ${Math.min(10, expectedConversations)}) r.sidebarPopulated = at();
|
||||
}
|
||||
const sidebarDone = ${expectedConversations} === 0 || r.sidebarPopulated !== undefined;
|
||||
return frameDone && sidebarDone && (r.composerInteractive !== undefined || (r.composerPresent !== undefined && r.startupDialog !== undefined));
|
||||
};
|
||||
let observer;
|
||||
let settled = false;
|
||||
const finish = () => { if (settled) return; settled = true; observer?.disconnect(); resolve(r); };
|
||||
if (check()) { finish(); return; }
|
||||
observer = new MutationObserver(() => { if (check()) finish(); });
|
||||
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['inert', 'hidden', 'disabled', 'aria-hidden', 'class'] });
|
||||
setTimeout(() => { r.timedOut = true; finish(); }, 60000);
|
||||
})`
|
||||
let probeResult
|
||||
async function installRendererProbe() {
|
||||
try { probeResult = await js(rendererProbe) } catch (error) { report.error = `renderer probe: ${error instanceof Error ? error.message : error}` }
|
||||
}
|
||||
|
||||
async function collectRenderer() {
|
||||
const data = await js(`(() => {
|
||||
const origin = performance.timeOrigin;
|
||||
const nav = performance.getEntriesByType('navigation')[0];
|
||||
const paint = Object.fromEntries(performance.getEntriesByType('paint').map(e => [e.name, origin + e.startTime]));
|
||||
const scripts = performance.getEntriesByType('resource').filter(e => e.initiatorType === 'script' || /\\.js$/.test(e.name));
|
||||
return {
|
||||
timeOrigin: origin,
|
||||
navigationStart: nav ? origin + nav.startTime : null,
|
||||
responseEnd: nav ? origin + nav.responseEnd : null,
|
||||
domInteractive: nav ? origin + nav.domInteractive : null,
|
||||
domContentLoaded: nav ? origin + nav.domContentLoadedEventEnd : null,
|
||||
loadEventEnd: nav ? origin + nav.loadEventEnd : null,
|
||||
firstPaint: paint['first-paint'] ?? null,
|
||||
firstContentfulPaint: paint['first-contentful-paint'] ?? null,
|
||||
scriptResources: scripts.length,
|
||||
scriptBytes: scripts.reduce((a, e) => a + (e.decodedBodySize || 0), 0),
|
||||
jsHeapMB: performance.memory ? performance.memory.usedJSHeapSize / 1048576 : null,
|
||||
domNodes: document.getElementsByTagName('*').length,
|
||||
conversationRows: document.querySelectorAll('button.conversation-item').length
|
||||
};
|
||||
})()`)
|
||||
return data
|
||||
}
|
||||
|
||||
// Long animation frames are buffered by Chromium, so the ones during startup
|
||||
// are visible even though the observer is installed after dom-ready.
|
||||
const loafProbe = `new Promise(resolve => {
|
||||
const items = [];
|
||||
try {
|
||||
new PerformanceObserver(list => { for (const e of list.getEntries()) items.push(e); }).observe({ type: 'long-animation-frame', buffered: true });
|
||||
} catch { resolve(null); return; }
|
||||
setTimeout(() => {
|
||||
const origin = performance.timeOrigin;
|
||||
resolve({ count: items.length, totalMs: items.reduce((a, e) => a + e.duration, 0), blockingMs: items.reduce((a, e) => a + e.blockingDuration, 0),
|
||||
maxMs: items.reduce((a, e) => Math.max(a, e.duration), 0),
|
||||
scriptMs: items.reduce((a, e) => a + e.scripts.reduce((s, x) => s + x.duration, 0), 0),
|
||||
frames: items.slice(0, 20).map(e => ({ at: origin + e.startTime, duration: e.duration, scriptMs: e.scripts.reduce((s, x) => s + x.duration, 0) })) });
|
||||
}, 50);
|
||||
})`
|
||||
|
||||
function profileHotspots(profile) {
|
||||
const byId = new Map(profile.nodes.map(node => [node.id, node]))
|
||||
const parent = new Map()
|
||||
for (const node of profile.nodes) for (const child of node.children ?? []) parent.set(child, node.id)
|
||||
const self = new Map()
|
||||
for (let index = 0; index < profile.samples.length; index += 1) {
|
||||
const id = profile.samples[index]
|
||||
self.set(id, (self.get(id) ?? 0) + (profile.timeDeltas[index] ?? 0))
|
||||
}
|
||||
const keyOf = frame => {
|
||||
const file = frame.url ? frame.url.replace(/^file:\/\/\/?/, '').split(/[\\/]/).slice(-3).join('/') : `(${frame.functionName || 'native'})`
|
||||
return `${frame.functionName || '(anonymous)'} ${file}:${frame.lineNumber + 1}`
|
||||
}
|
||||
const selfByKey = new Map()
|
||||
const inclusiveByKey = new Map()
|
||||
let total = 0
|
||||
for (const [id, micros] of self) {
|
||||
total += micros
|
||||
const key = keyOf(byId.get(id).callFrame)
|
||||
selfByKey.set(key, (selfByKey.get(key) ?? 0) + micros)
|
||||
const seen = new Set()
|
||||
for (let cursor = id; cursor !== undefined; cursor = parent.get(cursor)) {
|
||||
const k = keyOf(byId.get(cursor).callFrame)
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
inclusiveByKey.set(k, (inclusiveByKey.get(k) ?? 0) + micros)
|
||||
}
|
||||
}
|
||||
const top = (map, count, filter = () => true) => [...map].filter(([key]) => filter(key)).sort((a, b) => b[1] - a[1]).slice(0, count)
|
||||
.map(([key, micros]) => ({ key, ms: Math.round(micros / 100) / 10 }))
|
||||
const own = key => /out\/main\//.test(key)
|
||||
return {
|
||||
sampledMs: Math.round(total / 1000),
|
||||
selfTop: top(selfByKey, 30),
|
||||
inclusiveOutMain: top(inclusiveByKey, 40, own)
|
||||
}
|
||||
}
|
||||
|
||||
async function prepare() {
|
||||
await js(`window.goodbuddy.updates.updateSettings({ checkUpdatesOnStartup: false }).then(() => true)`)
|
||||
await js(`window.goodbuddy.releaseNotes.getPending().then(s => s.releases.some(r => r.version === s.currentVersion) ? window.goodbuddy.releaseNotes.acknowledge(s.currentVersion) : undefined).then(() => true)`)
|
||||
const count = Number(process.env.GB_STARTUP_SEED || 0)
|
||||
const perConversation = Number(process.env.GB_STARTUP_SEED_MESSAGES || 20)
|
||||
if (count > 0) {
|
||||
const projectId = await js(`window.goodbuddy.projects.list(false).then(list => list.find(p => p.kind === 'user' && p.executionSpace.kind === 'local')?.id)`)
|
||||
if (!projectId) throw new Error('No local project to seed')
|
||||
const base = Date.now()
|
||||
const items = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const updatedAt = base - (index + 1) * 60_000
|
||||
const messages = []
|
||||
for (let m = 0; m < perConversation; m += 1) {
|
||||
const role = m % 2 === 0 ? 'user' : 'assistant'
|
||||
const content = role === 'user'
|
||||
? `Question ${m}: how should the benchmark handle item ${m}?`
|
||||
: `## Answer ${m}\n\nThe benchmark answer for item ${m} contains **formatting**, \`inline code\`, and a list:\n\n- point one\n- point two\n\n\`\`\`ts\nconst value${m} = ${m}\n\`\`\``
|
||||
messages.push({ id: randomUUID(), role, content, createdAt: updatedAt - (perConversation - m) * 1000, state: 'complete' })
|
||||
}
|
||||
items.push({ header: { id: randomUUID(), projectId, title: `Perf conversation ${String(index + 1).padStart(4, '0')}`, updatedAt, knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' }, messages })
|
||||
}
|
||||
for (let offset = 0; offset < items.length; offset += 100) {
|
||||
await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify(items.slice(offset, offset + 100))}).then(() => true)`)
|
||||
}
|
||||
report.seeded = { conversations: count, messagesPerConversation: perConversation }
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
for (let i = 0; i < 600 && !win; i += 1) await sleep(50)
|
||||
if (!win) throw new Error('Main window was not created')
|
||||
for (let i = 0; i < 1200 && probeResult === undefined; i += 1) await sleep(25)
|
||||
if (!probeResult) throw new Error(report.error ?? 'Renderer probe did not finish')
|
||||
mark('probeResolvedInMain')
|
||||
if (profiler) {
|
||||
const { profile } = await profiler.postAsync('Profiler.stop')
|
||||
writeFileSync(profilePath, JSON.stringify(profile))
|
||||
report.cpuProfile = profileHotspots(profile)
|
||||
}
|
||||
Object.assign(report.renderer, probeResult)
|
||||
Object.assign(report.renderer, await collectRenderer())
|
||||
report.renderer.longAnimationFrames = await js(loafProbe).catch(() => null)
|
||||
report.startupMarks = (globalThis.__goodbuddyStartupMarks ?? []).map(({ name, at }) => ({ name, at: performance.timeOrigin + at }))
|
||||
report.mainEventLoopDelay = { maxMs: mainDelay.max / 1e6, p99Ms: mainDelay.percentile(99) / 1e6, meanMs: mainDelay.mean / 1e6 }
|
||||
report.flags.windowCount = BrowserWindow.getAllWindows().length
|
||||
if (mode === 'prepare') await prepare()
|
||||
report.status = 'passed'
|
||||
writeReport()
|
||||
clearTimeout(deadline)
|
||||
if (mode === 'prepare') app.quit()
|
||||
else app.exit(0)
|
||||
} catch (error) {
|
||||
report.status = 'failed'
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
writeReport()
|
||||
clearTimeout(deadline)
|
||||
app.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Electron emits `ready` only after this ESM entry finishes evaluating.
|
||||
void app.whenReady().then(run)
|
||||
Reference in New Issue
Block a user