startup-diagnostics.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { inspect } from 'node:util'
  5. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  6. import { StartupError } from '@deepseek-ai/dsh-app-boot'
  7. import { reportStartupFailure } from '../src/startup-diagnostics.ts'
  8. async function home(): Promise<string> {
  9. const dir = await mkdtemp(join(tmpdir(), 'dsh-startup-diagnostics-'))
  10. onTestFinished(() => rm(dir, { recursive: true, force: true }))
  11. return dir
  12. }
  13. function startupError(reason: unknown): StartupError {
  14. const error = new StartupError('dsh: startup failed: 1 required plugin did not activate', [
  15. { id: 'webserver', module: './webserver.mjs', required: true, fiberState: 3, outcome: { kind: 'failed', error: reason } },
  16. { id: 'waiting', module: './waiting.mjs', required: false, fiberState: 0, outcome: { kind: 'pending', missing: ['webServer'] } },
  17. ])
  18. error.startup = {
  19. configurationPath: '/example/cordis.yml',
  20. messages: [{ ts: 1, name: 'loader', type: 'error', args: [reason] }],
  21. }
  22. return error
  23. }
  24. describe('startup diagnostic files', () => {
  25. it('prints the summary and saved path to stderr by default', async () => {
  26. const dir = await home()
  27. const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
  28. if (typeof callback === 'function') callback()
  29. return true
  30. })
  31. onTestFinished(() => { write.mockRestore() })
  32. await reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })
  33. expect(write).toHaveBeenCalledWith(expect.stringContaining('dsh: startup failed:'), expect.any(Function))
  34. expect(write).toHaveBeenCalledWith(expect.stringContaining(`Full diagnostics: ${join(dir, 'logs')}`), expect.any(Function))
  35. })
  36. it('waits for stderr completion before resolving', async () => {
  37. const dir = await home()
  38. const pending: Array<() => void> = []
  39. const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
  40. if (typeof callback === 'function') pending.push(() => { callback() })
  41. return false
  42. })
  43. onTestFinished(() => { write.mockRestore() })
  44. let finished = false
  45. const report = reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })
  46. .then(() => { finished = true })
  47. expect(pending).toHaveLength(1)
  48. pending.shift()!()
  49. await vi.waitFor(() => { expect(pending).toHaveLength(1) })
  50. expect(finished).toBe(false)
  51. pending.shift()!()
  52. await report
  53. expect(finished).toBe(true)
  54. })
  55. it('rejects when stderr cannot complete the write', async () => {
  56. const dir = await home()
  57. const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
  58. if (typeof callback === 'function') callback(new Error('stderr closed'))
  59. return false
  60. })
  61. onTestFinished(() => { write.mockRestore() })
  62. await expect(reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' }))
  63. .rejects.toThrow('stderr closed')
  64. })
  65. it('retains original error properties, causes, aggregate members, cycles, and long values', async () => {
  66. const dir = await home()
  67. const chunks: string[] = []
  68. const large = 'x'.repeat(12_000) + 'END_OF_VALUE'
  69. const leaf = Object.assign(new Error('cannot listen'), { code: 'EADDRINUSE', payload: large })
  70. Object.defineProperty(leaf, 'hiddenDetail', { value: 'non-enumerable detail' })
  71. const symbol = Symbol('diagnostic-field')
  72. Object.assign(leaf, { [symbol]: 42n, self: leaf, values: Array.from({ length: 105 }, (_, i) => `value-${i}`) })
  73. let inspected = false
  74. let getterRead = false
  75. Object.defineProperty(leaf, inspect.custom, { value: () => { inspected = true; return 'hidden by custom inspector' } })
  76. Object.defineProperty(leaf, 'lazy', { get: () => { getterRead = true; return 'evaluated getter' } })
  77. const aggregate = new AggregateError([leaf, { transport: 'closed' }], 'activation failed', { cause: leaf })
  78. const error = startupError(aggregate)
  79. await reportStartupFailure(error, { home: dir, version: '1.2.3', profile: 'web' }, (text) => { chunks.push(text) })
  80. const files = await readdir(join(dir, 'logs'))
  81. expect(files).toHaveLength(1)
  82. expect(files[0]).toMatch(/^startup-[\w.-]+\.log$/u)
  83. const path = join(dir, 'logs', files[0]!)
  84. expect(chunks.join('')).toBe(`${error.message}\n\nFull diagnostics: ${path}\n`)
  85. const report = await readFile(path, 'utf8')
  86. expect(report.startsWith(
  87. 'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n',
  88. )).toBe(true)
  89. for (const text of [
  90. 'dshVersion: \'1.2.3\'', "profile: 'web'", process.version, 'configurationPath:', '/example/cordis.yml',
  91. "module: './waiting.mjs'", 'required: false', 'fiberState: 0', "missing: [ 'webServer'", 'messages:',
  92. 'AggregateError: activation failed', '[cause]', '[errors]', 'EADDRINUSE', '[hiddenDetail]',
  93. 'non-enumerable detail', 'Symbol(diagnostic-field)', '42n', '[Circular', large, 'value-104', '[Getter]',
  94. 'transport:', 'closed', 'at startupError',
  95. ]) expect(report).toContain(text)
  96. expect(inspected).toBe(false)
  97. expect(getterRead).toBe(false)
  98. if (process.platform !== 'win32') {
  99. expect((await stat(path)).mode & 0o777).toBe(0o600)
  100. expect((await stat(join(dir, 'logs'))).mode & 0o777).toBe(0o700)
  101. }
  102. })
  103. it('creates distinct files for concurrent failures without replacing earlier reports', async () => {
  104. const dir = await home()
  105. await Promise.all(['first', 'second'].map(reason => reportStartupFailure(
  106. startupError(reason), { home: dir, version: '1.2.3', profile: 'web' }, () => {},
  107. )))
  108. const files = await readdir(join(dir, 'logs'))
  109. expect(files).toHaveLength(2)
  110. const reports = await Promise.all(files.map(file => readFile(join(dir, 'logs', file), 'utf8')))
  111. expect(reports.filter(report => report.includes("error: 'first'"))).toHaveLength(1)
  112. expect(reports.filter(report => report.includes("error: 'second'"))).toHaveLength(1)
  113. })
  114. it('prints the complete report when the logs directory cannot be created', async () => {
  115. const dir = await home()
  116. await writeFile(join(dir, 'logs'), 'blocked')
  117. const chunks: string[] = []
  118. await reportStartupFailure(startupError({ code: 'CUSTOM', value: 'original details' }), {
  119. home: dir, version: '1.2.3', profile: 'web',
  120. }, (text) => { chunks.push(text) })
  121. const output = chunks.join('')
  122. expect(output).toContain('dsh: startup failed:')
  123. expect(output).toContain('dsh: warning: could not write startup diagnostics:')
  124. expect(output).toContain('Full diagnostics:\n')
  125. expect(output).toContain('original details')
  126. expect(output).toContain('CUSTOM')
  127. expect(output).not.toContain('Full diagnostics: ')
  128. expect(await readFile(join(dir, 'logs'), 'utf8')).toBe('blocked')
  129. })
  130. })