lazy-search-startup.compat.spec.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /**
  2. * Node 22 startup-output smoke for the shipped Web CLI composition.
  3. *
  4. * Only the dedicated Node compatibility gate opts this test in after building
  5. * both artifacts; ordinary Vitest inventory deterministically skips it.
  6. * The child runs built artifacts under plain Node with the real shipped
  7. * web profile (dsh-base + dsh-web-app bundle patches, auto-initialized).
  8. * Its URL line follows the settled profile boot; SIGTERM then exercises the
  9. * shipped quiescent disposer.
  10. */
  11. import { spawn } from 'node:child_process'
  12. import { existsSync } from 'node:fs'
  13. import { mkdtemp, readFile, rm } from 'node:fs/promises'
  14. import { tmpdir } from 'node:os'
  15. import { join, resolve } from 'node:path'
  16. import { fileURLToPath } from 'node:url'
  17. import yaml from 'js-yaml'
  18. import { describe, expect, it } from 'vitest'
  19. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  20. const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
  21. const webDist = join(repoRoot, 'apps/web/dist/index.html')
  22. // Full-text session search ships off (`openAt: never` on both layers): the
  23. // base patch carries the default, and the web restatement must not re-enable it.
  24. const baseConfigPath = join(repoRoot, 'packages/bundle/base/cordis.patch.yml')
  25. const webConfigPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml')
  26. const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
  27. interface ConfigRow {
  28. id?: string
  29. disabled?: unknown
  30. config?: { openAt?: unknown }
  31. }
  32. interface PatchEntry extends ConfigRow {
  33. insert?: ConfigRow[]
  34. }
  35. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  36. kind: 'scalar',
  37. construct: value => String(value),
  38. })
  39. const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
  40. /** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
  41. function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
  42. return new Promise((resolveRun, rejectRun) => {
  43. const env: NodeJS.ProcessEnv = {
  44. ...process.env,
  45. DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
  46. DSH_HOME: join(cwd, '.dsh'),
  47. }
  48. delete env.DEEPSEEK_BASE_URL
  49. delete env.NODE_OPTIONS
  50. delete env.NODE_NO_WARNINGS
  51. const child = spawn(process.execPath, [
  52. builtBin,
  53. 'web',
  54. '--no-open',
  55. '--host',
  56. '127.0.0.1',
  57. '--port',
  58. '0',
  59. ], {
  60. cwd,
  61. env,
  62. stdio: ['ignore', 'pipe', 'pipe'],
  63. })
  64. let stdout = ''
  65. let stderr = ''
  66. let settled = false
  67. child.stdout.setEncoding('utf8')
  68. child.stderr.setEncoding('utf8')
  69. child.stdout.on('data', (chunk: string) => {
  70. stdout += chunk
  71. if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
  72. settled = true
  73. child.kill('SIGTERM')
  74. }
  75. })
  76. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  77. const timer = setTimeout(() => {
  78. child.kill('SIGKILL')
  79. rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
  80. }, 60_000)
  81. child.on('error', (error) => {
  82. clearTimeout(timer)
  83. rejectRun(error)
  84. })
  85. child.on('close', (code) => {
  86. clearTimeout(timer)
  87. if (!settled) {
  88. rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
  89. return
  90. }
  91. resolveRun({ stdout, stderr, code: code ?? -1 })
  92. })
  93. })
  94. }
  95. describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
  96. it('boots and disposes the shipped composition with full-text search off by default', async () => {
  97. expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
  98. expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
  99. const baseRows = (yaml.load(await readFile(baseConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[])
  100. .flatMap(entry => entry.insert ?? [entry])
  101. const webRows = (yaml.load(await readFile(webConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[])
  102. .flatMap(entry => entry.insert ?? [entry])
  103. const baseRow = baseRows.find(row => row.id === 'session-query-sqlite')
  104. const webRow = webRows.find(row => row.id === 'session-query-sqlite')
  105. expect(baseRow?.config?.openAt).toBe('never')
  106. expect(baseRow?.disabled).toBeUndefined()
  107. // The web restatement keeps the shipped default; opting in is a later layer's override.
  108. expect(webRow?.config?.openAt).toBe('never')
  109. expect(webRow?.disabled).toBeUndefined()
  110. const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
  111. try {
  112. const result = await runBuiltWeb(cwd)
  113. expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
  114. expect(result.code).toBe(0)
  115. expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
  116. } finally {
  117. await rm(cwd, { recursive: true, force: true })
  118. }
  119. }, 70_000)
  120. })