Files
goodbuddy/src/main/desktop-diagnostics.test.ts
T
mesalogo b755951ecd fix: offer recovery for blank desktop windows
React render failures now show a localized reload page, and renderer crashes offer native reload confirmation. Record renderer and load failures in existing diagnostics without automatic retry loops. Reload may lose unsaved input; the intermittent failure cause remains unconfirmed.

Release note: Recover from desktop rendering failures using a visible Reload action instead of being left without a recovery entry point.
2026-09-15 08:07:08 +08:00

245 lines
7.4 KiB
TypeScript

import {
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import {
DesktopDiagnostics,
MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES,
normalizeDesktopDiagnosticRecord
} from './desktop-diagnostics'
const temporaryDirectories: string[] = []
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-desktop-diagnostics-')
)
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('DesktopDiagnostics', () => {
it('persists renderer failure codes without storing raw error content', async () => {
const diagnostics = new DesktopDiagnostics(await temporaryDirectory())
for (const code of ['desktop.renderer.gone', 'desktop.renderer.load-failed']) {
await diagnostics.recordFailure({
component: 'desktop', stage: 'renderer', code,
error: new Error('private renderer details')
})
}
const records = await diagnostics.readRecent()
expect(records.map(record => record.code)).toEqual([
'desktop.renderer.gone', 'desktop.renderer.load-failed'
])
expect(records.every(record => record.message === 'Desktop renderer failed')).toBe(true)
expect(JSON.stringify(records)).not.toContain('private renderer details')
await diagnostics.dispose()
})
it('rotates within fixed file and byte bounds', async () => {
const directory = await temporaryDirectory()
let sequence = 0
const diagnostics = new DesktopDiagnostics(directory, {
maximumFileBytes: 420,
maximumFiles: 3,
now: () => new Date(sequence++ * 1_000)
})
for (let index = 0; index < 12; index += 1) {
await diagnostics.recordFailure({
component: 'runtime',
stage: 'run',
code: 'runtime.run.failed',
error: new Error(`failure ${index}`)
})
}
await diagnostics.dispose()
const files = await readdir(directory)
expect(files).toHaveLength(3)
for (const file of files) {
expect((await stat(join(directory, file))).size).toBeLessThanOrEqual(
420
)
}
const reopened = new DesktopDiagnostics(directory, {
maximumFileBytes: 420,
maximumFiles: 3
})
const records = await reopened.readRecent()
expect(records.length).toBeGreaterThan(0)
expect(records.length).toBeLessThan(12)
expect(records.at(-1)).toMatchObject({
component: 'runtime',
stage: 'run',
code: 'runtime.run.failed',
message: 'Runtime request failed'
})
await reopened.dispose()
})
it('reads bounded recent records after restart', async () => {
const directory = await temporaryDirectory()
const first = new DesktopDiagnostics(directory, {
maximumRecords: 2
})
await first.recordFailure({
component: 'desktop',
stage: 'startup',
code: 'desktop.startup.failed',
error: new TypeError('first')
})
await first.recordFailure({
component: 'remote-agent',
stage: 'connect',
code: 'remote.connection.network',
error: new Error('second')
})
await first.recordFailure({
component: 'runtime',
stage: 'status',
code: 'runtime.operation.failed',
error: new Error('third')
})
await first.dispose()
const restarted = new DesktopDiagnostics(directory, {
maximumRecords: 2
})
await expect(restarted.readRecent(100)).resolves.toMatchObject([
{ code: 'remote.connection.network' },
{ code: 'runtime.operation.failed' }
])
const exported = await restarted.exportRecent()
expect(exported.toString('utf8').trim().split('\n')).toHaveLength(2)
await restarted.dispose()
})
it('never persists raw error content or unknown error names', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
const secret =
'Prompt body password=hunter2 API_KEY=abc https://provider.test/path?token=raw-response'
const error = new Error(secret)
error.name = `Credential-${secret}`
await diagnostics.recordFailure({
component: 'runtime',
stage: secret,
code: secret,
error
})
const exported = await diagnostics.exportRecent()
await diagnostics.dispose()
expect(exported.toString('utf8')).not.toContain(secret)
expect(exported.toString('utf8')).not.toContain('hunter2')
expect(exported.toString('utf8')).not.toContain('provider.test')
expect(JSON.parse(exported.toString('utf8'))).toMatchObject({
errorType: 'Error',
stage: 'unknown',
code: 'diagnostic.failure',
message: 'Runtime operation failed'
})
const persisted = await readFile(
join(directory, 'desktop-diagnostics.ndjson'),
'utf8'
)
expect(persisted).not.toContain('API_KEY')
})
it('uses one safe normalizer for externally damaged records', async () => {
const directory = await temporaryDirectory()
const damaged = {
timestamp: '2026-08-25T01:02:03+00:00',
component: 'remote-agent',
stage: 'disconnect',
code: 'remote.connection.lost',
errorType: 'SensitiveProviderError',
message: 'password=hunter2'
}
expect(normalizeDesktopDiagnosticRecord(damaged)).toEqual({
timestamp: '2026-08-25T01:02:03.000Z',
component: 'remote-agent',
stage: 'disconnect',
code: 'remote.connection.lost',
errorType: 'Error',
message: 'Remote connection was lost'
})
await writeFile(
join(directory, 'desktop-diagnostics.ndjson'),
`${JSON.stringify(damaged)}\n`,
'utf8'
)
const diagnostics = new DesktopDiagnostics(directory)
const records = await diagnostics.readRecent()
await diagnostics.dispose()
expect(records).toEqual([
{
timestamp: '2026-08-25T01:02:03.000Z',
component: 'remote-agent',
stage: 'disconnect',
code: 'remote.connection.lost',
errorType: 'Error',
message: 'Remote connection was lost'
}
])
expect(JSON.stringify(records)).not.toContain('hunter2')
})
it('normalizes errors immediately and bounds pending writes', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
let errorNameReads = 0
const writes = Array.from(
{
length:
MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES + 8
},
() => {
const error = new Error('raw provider failure')
Object.defineProperty(error, 'name', {
get: () => {
errorNameReads += 1
return 'TypeError'
}
})
return diagnostics.recordFailure({
component: 'runtime',
stage: 'run',
code: 'runtime.run.failed',
error
})
}
)
expect(errorNameReads).toBe(writes.length)
expect(diagnostics.pendingWriteCount).toBe(
MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES
)
await Promise.all(writes)
expect(diagnostics.pendingWriteCount).toBe(0)
await expect(diagnostics.readRecent()).resolves.toHaveLength(
MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES
)
await diagnostics.dispose()
})
})