Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { expect, test } from '../fixtures/electron.mjs'

const { unzipSync, strFromU8 } = createRequire(new URL('../../../apps/buddy/package.json', import.meta.url))('fflate/node')

test('performance capture stays local, identifies processes and exports bounded metadata', async ({ buddy }, testInfo) => {
const instance = await buddy.createInstance('performance-diagnostics')
const { page, app, diagnostics } = await instance.launch()
await page.evaluate(() => window.location.hash = '/settings/logs')
const header = page.locator('.desktop-settings-page__header')
const panel = page.getByTestId('performance-diagnostics')
await expect(header.getByRole('button', { name: '性能诊断' })).toBeVisible()
await expect(header.getByRole('button', { name: '导出诊断包' })).toBeVisible()
await expect(panel).toHaveCount(0)
const live = header.getByRole('switch')
await expect(live).toBeChecked()
await live.click()
await expect(live).not.toBeChecked()
await expect(page.locator('.application-logs__status')).toContainText('已暂停更新')
await live.click()
await expect(live).toBeChecked()
await testInfo.attach('logs-header', { body: await page.screenshot(), contentType: 'image/png' })

const extra = await app.evaluate(async ({ BrowserWindow }) => {
const window = new BrowserWindow({ show: false })
await window.loadURL('data:text/html,<p>Diagnostic fixture</p>')
return { id: window.id, pid: window.webContents.getOSProcessId() }
})
await header.getByRole('button', { name: '性能诊断' }).click()
await expect(panel).toBeVisible()
await expect(panel.getByText('界面 · 当前窗口', { exact: true })).toBeVisible()
const collectionDetails = page.getByText('仅记录性能摘要,不含对话、文件内容或原始堆栈。指标不含 FFmpeg 等外部子进程和桌宠。', { exact: true })
await panel.getByRole('button', { name: '采集说明', exact: true }).click()
await expect(collectionDetails).toBeVisible()
await panel.getByRole('button', { name: '采集说明', exact: true }).click()
await expect(collectionDetails).not.toBeVisible()
const otherRenderer = panel.getByRole('row').filter({ hasText: String(extra.pid) })
await expect(otherRenderer).toContainText('仅显示指标', { timeout: 12000 })
await expect(otherRenderer.getByRole('button')).toHaveCount(0)
await expect(panel.getByRole('button', { name: '分析 5 秒', exact: true })).toHaveCount(3)
const wrongTargetRejected = await page.evaluate(async () => {
try {
await window.lexoraDesktop.app.performance.capture({ target: 'runtime', pid: 0 })
return false
}
catch { return true }
})
expect(wrongTargetRejected).toBe(true)

const processNames = panel.locator('tbody tr td:first-child')
await expect.poll(() => processNames.allTextContents()).toEqual(['主进程', '任务运行时', '界面 · 当前窗口', '界面', '图形', '网络'])
const processPids = await panel.locator('tbody tr td:nth-child(2)').allTextContents()
const runtime = panel.getByRole('row').filter({ hasText: '任务运行时' })
const runtimePid = (await runtime.locator('td').nth(1).textContent()).trim()
await runtime.getByRole('button', { name: '分析 5 秒' }).click()
await expect(panel.getByRole('status')).toContainText(`正在分析任务运行时(PID ${runtimePid})`)
await expect(panel.getByRole('button', { name: '关闭', exact: true })).toBeDisabled()
await expect(panel.getByRole('status')).toContainText(`已完成任务运行时(PID ${runtimePid})的分析`, { timeout: 15000 })
expect(await panel.locator('tbody tr td:nth-child(2)').allTextContents()).toEqual(processPids)
await testInfo.attach('performance-dialog', { body: await page.screenshot(), contentType: 'image/png' })

const results = []
for (const target of ['main', 'renderer']) {
const result = await page.evaluate(async (target) => {
const api = window.lexoraDesktop.app.performance
const snapshot = await api.snapshot()
const pid = target === 'renderer' ? snapshot.rendererPid : snapshot.samples.at(-1).processes.find(item => item.role === 'main').pid
return api.capture({ target, pid })
}, target)
expect(result).toMatchObject({ target, pid: expect.any(Number) })
expect(result.durationMs).toBeGreaterThan(4500)
expect(result.samples).toBeGreaterThan(0)
expect(result.hotspots.length).toBeLessThanOrEqual(20)
expect(JSON.stringify(result)).not.toMatch(/file:\/\/|\/home\/|functionName|scriptId|callFrame/)
results.push(result)
}
const snapshot = await page.evaluate(() => window.lexoraDesktop.app.performance.snapshot())
expect(snapshot.coverage).toBe('electron-processes')
expect(snapshot.samples.length).toBeLessThanOrEqual(60)
expect(snapshot.samples.at(-1).logicalCpuCount).toBeGreaterThan(0)
const filename = path.join(instance.home, 'diagnostics.zip')
await app.evaluate(({ dialog }, filename) => {
dialog.showSaveDialog = async () => ({ canceled: false, filePath: filename })
}, filename)
await panel.getByRole('button', { name: '导出诊断包' }).click()
await expect(panel).toHaveCount(0)
await page.getByRole('button', { name: '导出 ZIP', exact: true }).click()
await expect.poll(async () => fs.stat(filename).then(stat => stat.size).catch(() => 0)).toBeGreaterThan(0)
const archive = unzipSync(await fs.readFile(filename))
const context = strFromU8(archive['context.jsonl']).trim().split('\n').map(line => JSON.parse(line))
expect(context.filter(record => record.cpuProfile).map(record => record.cpuProfile.target).sort()).toEqual(['main', 'renderer', 'runtime'])
expect(context.find(record => record.cpuProfile?.target === 'runtime').cpuProfile.pid).toBe(Number(runtimePid))
expect(strFromU8(archive['context.jsonl'])).not.toMatch(/functionName|scriptId|callFrame|file:\/\/|\/home\//)
const summaryPath = testInfo.outputPath('performance-summary.json')
await fs.writeFile(summaryPath, JSON.stringify({ profiles: results, snapshot }, null, 2))
await testInfo.attach('performance-summary', { path: summaryPath, contentType: 'application/json' })

await header.getByRole('button', { name: '性能诊断' }).click()
await expect(panel).toBeVisible()
await page.keyboard.press('Escape')
await expect(panel).toHaveCount(0)
await app.evaluate(({ BrowserWindow }, id) => BrowserWindow.fromId(id)?.close(), extra.id)
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})

test('renderer CPU summaries retain locations from bundled Vue code', async ({ buddy }, testInfo) => {
const instance = await buddy.createInstance('renderer-cpu-locations')
const { page } = await instance.launch()
const { filename, profile } = await page.evaluate(async () => {
const url = document.querySelector('link[rel="modulepreload"][href*="/vue.runtime.esm-bundler-"]')?.href
if (!url)
throw new Error('Bundled Vue runtime was not loaded')
const exports = await import(url)
const vue = Object.values(exports).find(value => value && typeof value === 'object' && typeof value.reactive === 'function' && typeof value.computed === 'function')
if (!vue)
throw new Error('Bundled Vue runtime exports were not found')
const state = vue.reactive({ count: 0 })
const doubled = vue.computed(() => state.count * 2)
let observed = 0
const timer = setInterval(() => {
const until = performance.now() + 20
while (performance.now() < until) {
state.count++
observed = doubled.value
}
}, 25)
try {
const api = window.lexoraDesktop.app.performance
const { rendererPid } = await api.snapshot()
const profile = await api.capture({ target: 'renderer', pid: rendererPid })
if (!observed)
throw new Error('Reactive workload did not run')
return { filename: new URL(url).pathname.split('/').at(-1), profile }
}
finally {
clearInterval(timer)
}
})
const code = createHash('sha256').update(filename).digest('hex').slice(0, 12)
expect(profile.hotspots.some(hotspot => hotspot.location.startsWith(`renderer:${code}:`) && hotspot.selfMs > 0)).toBe(true)
expect(JSON.stringify(profile)).not.toMatch(/vue\.runtime|functionName|scriptId|callFrame|lexora-app:|file:/)
await testInfo.attach('renderer-cpu-locations', { body: JSON.stringify({ filename, profile }, null, 2), contentType: 'application/json' })
})
2 changes: 1 addition & 1 deletion apps/buddy/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default defineConfig({
build: {
outDir: join(electronOutputRoot, 'main'),
rollupOptions: {
external: ['electron', '@silvia-odwyer/photon-node'],
external: ['electron', '@silvia-odwyer/photon-node', 'fflate/node'],
input: {
'index': fileURLToPath(new URL('./electron/main/index.ts', import.meta.url)),
'buddy-service': fileURLToPath(new URL('./service/src/index.ts', import.meta.url)),
Expand Down
98 changes: 43 additions & 55 deletions apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,86 +162,71 @@ describe('desktop diagnostics', () => {
appVersion: '0.3.0',
platform: process.platform,
collectorPid: process.pid,
message: 'first\nsecond',
operationId: 'startup-1',
durationMs: 12,
})
expect(records[0]!.launchId).toBe(records[1]!.launchId)
expect(records[1]!.error).toMatchObject({ name: 'Error', code: 'EACCES', message: 'failed <home>/workspace token=<redacted>' })
expect(records[1]).toMatchObject({ errorType: 'Error', errorCode: 'EACCES' })
expect(records[0]).not.toHaveProperty('message')
expect(records[1]).not.toHaveProperty('error')
expect(JSON.stringify(records)).not.toContain('fixture-')
expect(records[1]!.error).not.toHaveProperty('request')
if (process.platform !== 'win32') {
expect((await stat(directory)).mode & 0o777).toBe(0o700)
expect((await stat(join(directory, 'application.jsonl'))).mode & 0o777).toBe(0o600)
}
})

it('reassembles UTF-8 and secrets across chunks before redaction', async () => {
it('counts split UTF-8 and arbitrary stderr without retaining any content', async () => {
const { directory, logger } = await createLogger()
const output = logger.createWritable('local-service')
const bytes = Buffer.from('中文 Authorization: Bearer fixture-secret\r\n next line\nlast line')
const bytes = Buffer.from('中文 私密用户文本 /project/customer.mov Authorization: Bearer fixture-secret')
for (const byte of bytes)
output.write(Buffer.from([byte]))
await logger.close()
const records = await readRecords(directory)
expect(records.map(record => record.message)).toEqual([
'中文 Authorization: <redacted>',
' next line',
'last line',
])
expect(new Set(records.map(record => record.sourceId)).size).toBe(1)
expect(records.every(record => record.scope === 'local-service')).toBe(true)
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({ scope: 'local-service', output: { bytes: bytes.length, chunks: bytes.length } })
expect(JSON.stringify(records)).not.toMatch(/私密|customer|fixture-secret/)
expect(records[0]).not.toHaveProperty('message')
})

it('does not emit partial lines during flush and continues writing after flush', async () => {
it('aggregates stderr until its fixed interval or EOF without holding output bytes', async () => {
const { directory, logger } = await createLogger()
const output = logger.createWritable('local-service')
output.write('Authorization: Bea')
output.write('private text')
await logger.flush()
expect(logger.status.accepted).toBe(0)
output.write('rer fixture-secret\n')
await logger.flush()
for (let index = 0; index < 4; index++) {
logger.record({ ...event, message: `next-${index}` })
await logger.flush()
}
output.end('more text')
await logger.close()
expect((await readRecords(directory)).map(record => record.message))
.toEqual(['Authorization: <redacted>', 'next-0', 'next-1', 'next-2', 'next-3'])
expect((await readRecords(directory))[0]?.output).toEqual({ bytes: 21, chunks: 2 })
})

it('discards an oversized line in full and resumes at the next newline', async () => {
it('does not serialize arbitrary messages, error stacks or nested payloads', async () => {
const { directory, logger } = await createLogger()
const output = logger.createWritable('native-pet')
output.write('x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES))
output.write('Authorization: Bea')
output.write('rer fixture-secret\nrecovered\n')
expect(logger.record({ ...event, message: 'x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })).toBe(false)
expect(logger.record({ ...event, message: 'private'.repeat(100000), error: new Error('unlabelled personal text') })).toBe(true)
await logger.close()
const records = await readRecords(directory)
expect(records.filter(record => record.scope === 'native-pet').map(record => record.message)).toEqual(['recovered'])
expect(logger.status.dropped).toBe(2)
expect(records.find(record => record.event === 'recorder.loss')?.recorderLoss).toEqual({ dropped: 2, failed: 0 })
expect(JSON.stringify(records)).not.toContain('fixture-secret')
expect(records[0]).toMatchObject({ errorType: 'Error', errorCode: 'OPERATION_FAILED' })
expect(JSON.stringify(records)).not.toMatch(/private|unlabelled/)
})

it('bounds the pending queue without accumulating a Writable backlog', async () => {
const { directory, logger } = await createLogger({ maxQueueBytes: MAX_DIAGNOSTIC_RECORD_BYTES })
const output = logger.createWritable('local-service')
for (let index = 0; index < 100; index++)
output.write(`${index} ${'x'.repeat(2000)}\n`)
it('rejects a log storm before accessing payloads and bounds the pending queue', async () => {
const { logger } = await createLogger({ maxQueueBytes: MAX_DIAGNOSTIC_RECORD_BYTES })
expect(logger.record(event)).toBe(true)
const payload = { ...event, get error() {
throw new Error('must not inspect rejected payload')
} }
for (let index = 0; index < 10000; index++)
expect(logger.record(payload)).toBe(false)
expect(logger.status.pendingBytes).toBeLessThanOrEqual(MAX_DIAGNOSTIC_RECORD_BYTES)
expect(output.writableLength).toBe(0)
expect(logger.status.dropped).toBeGreaterThan(0)
expect(logger.status.dropped).toBe(10000)
await logger.close()
const records = await readRecords(directory)
expect(records.filter(record => record.scope === 'local-service').length + logger.status.dropped).toBe(100)
expect(logger.status.pendingBytes).toBe(0)
})

it('persists a loss summary even when every input was rejected', async () => {
const { directory, logger } = await createLogger()
logger.record({ ...event, message: 'x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })
logger.record({ ...event, event: 'INVALID_EVENT' })
expect(await logger.close()).toMatchObject({ dropped: 1, written: 1 })
expect((await readRecords(directory))[0]).toMatchObject({
event: 'recorder.loss',
Expand All @@ -251,7 +236,7 @@ describe('desktop diagnostics', () => {

it('reports bounded close loss once through stderr without writing back to the closed sink', async () => {
const { directory, logger } = await createLogger()
logger.record({ ...event, message: 'fixture-private'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })
logger.record({ ...event, event: 'INVALID_EVENT' })
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await closeDesktopDiagnostics(logger)
await closeDesktopDiagnostics(logger)
Expand All @@ -263,17 +248,16 @@ describe('desktop diagnostics', () => {

it('bounds file size and count while retaining the latest complete records', async () => {
const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 3 })
for (let index = 0; index < 8; index++) {
logger.record({ ...event, operationId: String(index), message: 'x'.repeat(10000) })
for (let index = 0; index < 150; index++) {
logger.record({ ...event, operationId: String(index), component: 'component'.repeat(10) })
await logger.flush()
}
await logger.close()
const files = await readdir(directory)
expect(files.sort()).toEqual(['application.1.jsonl', 'application.2.jsonl', 'application.jsonl'])
for (const file of files)
expect((await stat(join(directory, file))).size).toBeLessThanOrEqual(MAX_DIAGNOSTIC_RECORD_BYTES)
expect((await readRecords(directory))[0]!.operationId).toBe('7')
expect((await readRecords(directory, 'application.2.jsonl'))[0]!.operationId).toBe('5')
expect((await readRecords(directory)).at(-1)!.operationId).toBe('149')
})

it('starts a clean file after a previous launch ended with an incomplete line', async () => {
Expand All @@ -298,7 +282,7 @@ describe('desktop diagnostics', () => {
const output = logger.createWritable('local-service')
const errors: Error[] = []
output.on('error', error => errors.push(error))
output.write('first\n')
logger.record(event)
expect(await logger.flush()).toMatchObject({ failed: 1, written: 0, pendingBytes: 0 })
expect(logger.status.lastError).toBeTruthy()
expect(errors).toEqual([])
Expand All @@ -312,15 +296,18 @@ describe('desktop diagnostics', () => {
expect(logger.status.failed).toBe(1)
})

it('isolates rotation failure and reports it without overwriting existing records', async () => {
it('isolates rotation failure and preserves already written records', async () => {
const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 2 })
logger.record({ ...event, message: 'x'.repeat(10000) })
logger.record({ ...event, operationId: 'first' })
await logger.flush()
await mkdir(join(directory, 'application.1.jsonl'))
logger.record({ ...event, message: 'y'.repeat(10000) })
for (let index = 0; index < 100 && !logger.status.failed; index++) {
logger.record({ ...event, operationId: String(index), component: 'component'.repeat(10) })
await logger.flush()
}
await logger.close()
expect(logger.status.failed).toBe(1)
expect((await readRecords(directory))[0]!.message).toBe('x'.repeat(10000))
expect((await readRecords(directory))[0]!.operationId).toBe('first')
})

it('waits for captured stream EOF and keeps process generations separate', async () => {
Expand All @@ -337,7 +324,7 @@ describe('desktop diagnostics', () => {
second.end('rer fixture-secret')
expect(await closing).toMatchObject({ written: 2, closeTimedOut: false })
const records = await readRecords(directory)
expect(records.map(record => record.message)).toEqual(['first tail', 'Authorization: <redacted>'])
expect(records.map(record => record.output?.bytes)).toEqual([10, 36])
expect(records[0]!.sourceId).not.toBe(records[1]!.sourceId)
})

Expand All @@ -361,7 +348,8 @@ describe('desktop diagnostics', () => {
expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false })
expect((await readRecords(directory))[0]).toMatchObject({
event: 'process.stderr_failed',
error: { message: 'stderr read failed' },
errorType: 'Error',
errorCode: 'OPERATION_FAILED',
})
})

Expand Down Expand Up @@ -391,6 +379,6 @@ describe('desktop diagnostics', () => {
const error = await new Promise<Error | null | undefined>(resolve => output.write('late\n', resolve))
expect(error).toBeInstanceOf(Error)
expect(logger.record(event)).toBe(false)
expect((await readRecords(directory)).map(record => record.message)).toEqual(['tail'])
expect((await readRecords(directory)).map(record => record.output?.bytes)).toEqual([4])
})
})
Loading