publint-all.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { once } from 'node:events'
  6. import { execa } from 'execa'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
  9. const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
  10. const roots: string[] = []
  11. const children: Array<{ kill: () => void; closed: Promise<unknown> }> = []
  12. afterEach(async () => {
  13. // A test timeout can reach teardown before the test's pending await settles.
  14. const ownedRoots = roots.splice(0)
  15. await Promise.all(children.splice(0).map(async ({ kill, closed }) => {
  16. kill()
  17. await closed
  18. }))
  19. for (const root of ownedRoots) rmSync(root, { recursive: true, force: true })
  20. })
  21. function fixture(options: {
  22. exportPath?: string
  23. indexSource?: string
  24. files?: Record<string, string>
  25. } = {}): string {
  26. const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
  27. roots.push(root)
  28. const packageDir = join(root, 'packages/core/probe')
  29. mkdirSync(join(packageDir, 'lib'), { recursive: true })
  30. writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
  31. name: '@deepseek-ai/dsh-probe',
  32. version: '0.0.1',
  33. type: 'module',
  34. license: 'MIT',
  35. engines: { node: '>=22.19' },
  36. sideEffects: false,
  37. files: ['lib'],
  38. exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
  39. }, null, 2)}\n`)
  40. writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
  41. writeFileSync(join(packageDir, 'lib/index.js'), options.indexSource ?? 'export const probe = true\n')
  42. for (const [path, source] of Object.entries(options.files ?? {})) {
  43. mkdirSync(join(packageDir, path, '..'), { recursive: true })
  44. writeFileSync(join(packageDir, path), source)
  45. }
  46. writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
  47. return root
  48. }
  49. /** Own direct Node children until close; Vitest's signal supplies the lane deadline. */
  50. function start(args: string[], signal: AbortSignal, cwd = repositoryRoot) {
  51. const child = execa(process.execPath, args, {
  52. cwd,
  53. cancelSignal: signal,
  54. killSignal: 'SIGKILL',
  55. reject: false,
  56. stdin: 'ignore',
  57. stripFinalNewline: false,
  58. })
  59. // `error` is an outcome, not the completion edge for the process and its pipes.
  60. const closed = new Promise<void>(resolve => child.nodeChildProcess.once('close', () => { resolve() }))
  61. const result = Promise.all([child, closed]).then(([result]) => result)
  62. children.push({ kill: () => { child.kill('SIGKILL') }, closed: result })
  63. return { child, result }
  64. }
  65. function expectCompleted(result: Awaited<ReturnType<typeof start>['result']>) {
  66. const diagnostics = [
  67. `publint subprocess: error=${String(result.cause)}; signal=${String(result.signal)}; exitCode=${String(result.exitCode)}`,
  68. `canceled=${result.isCanceled}; timedOut=${result.timedOut}`,
  69. result.shortMessage ?? '',
  70. `stdout:\n${result.stdout}`,
  71. `stderr:\n${result.stderr}`,
  72. ].join('\n')
  73. expect(result.cause, diagnostics).toBeUndefined()
  74. expect(result.isCanceled, diagnostics).toBe(false)
  75. expect(result.timedOut, diagnostics).toBe(false)
  76. expect(result.signal, diagnostics).toBeUndefined()
  77. }
  78. async function run(root: string, signal: AbortSignal) {
  79. const { result } = start([
  80. '--import', 'tsx', runner,
  81. '--packages-root', root,
  82. ], signal)
  83. const completed = await result
  84. expectCompleted(completed)
  85. return completed
  86. }
  87. describe('publint package runner', () => {
  88. it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
  89. const deadline = new AbortController()
  90. const active = [0, 1].map(() => start([
  91. '-e', "process.stderr.write('probe stderr\\n'); process.stdout.write('ready\\n'); setInterval(() => {}, 1000)",
  92. ], AbortSignal.any([signal, deadline.signal])))
  93. const closed = active.map(() => false)
  94. active.forEach(({ child }, index) => child.nodeChildProcess.once('close', () => { closed[index] = true }))
  95. await Promise.all(active.map(({ child }) => once(child.stdout, 'data', { signal })))
  96. expect(closed).toEqual([false, false])
  97. // Start the deadline only after both children announce readiness; startup speed is not the oracle.
  98. const timer = setTimeout(() => { deadline.abort(new DOMException('fixture deadline expired', 'TimeoutError')) }, 0)
  99. try {
  100. const results = await Promise.all(active.map(({ result }) => result))
  101. expect(closed).toEqual([true, true])
  102. for (const [index, result] of results.entries()) {
  103. expect(result.isCanceled).toBe(true)
  104. expect(() => { expectCompleted(result) }).toThrow(/publint subprocess: error=TimeoutError: fixture deadline expired; signal=/)
  105. expect(() => { expectCompleted(result) }).toThrow(/canceled=true/)
  106. expect(() => { expectCompleted(result) }).toThrow(/ready/)
  107. expect(() => { expectCompleted(result) }).toThrow(/probe stderr/)
  108. expect(() => process.kill(active[index]!.child.pid!, 0)).toThrow(/ESRCH/)
  109. }
  110. } finally {
  111. clearTimeout(timer)
  112. }
  113. })
  114. it('reports spawn errors before checking the expected exit code', async ({ signal }) => {
  115. const { result } = start(['-e', ''], signal, join(fixture(), 'missing-cwd'))
  116. const completed = await result
  117. expect(completed.cause).toMatchObject({ code: 'ENOENT' })
  118. expect(() => { expectCompleted(completed) }).toThrow(/publint subprocess: error=.*ENOENT.*; signal=undefined/)
  119. })
  120. it('lints recursively declared files from an in-memory publication view', async ({ signal }) => {
  121. const result = await run(fixture(), signal)
  122. expect(result.exitCode, result.stderr).toBe(0)
  123. expect(result.stdout).toContain('linting 1 package(s)')
  124. expect(result.stdout).toContain('All good!')
  125. })
  126. it('rejects an export that exists in the workspace but is not published', async ({ signal }) => {
  127. const result = await run(fixture({ exportPath: './unpublished.js' }), signal)
  128. expect(result.exitCode).toBe(1)
  129. expect(result.stdout).toContain('unpublished.js')
  130. })
  131. it('rejects a public export whose built file is missing', async ({ signal }) => {
  132. const result = await run(fixture({ exportPath: './lib/missing.js' }), signal)
  133. expect(result.exitCode).toBe(1)
  134. expect(result.stdout).toContain('missing.js')
  135. })
  136. it('accepts published relative JavaScript and CSS targets', async ({ signal }) => {
  137. const result = await run(fixture({
  138. indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
  139. files: {
  140. 'lib/helper.js': 'export const helper = true\n',
  141. 'lib/theme.css': ':root {}\n',
  142. },
  143. }), signal)
  144. expect(result.exitCode, result.stderr).toBe(0)
  145. })
  146. it('rejects unpublished relative JavaScript and CSS targets', async ({ signal }) => {
  147. const result = await run(fixture({
  148. indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
  149. }), signal)
  150. expect(result.exitCode).toBe(1)
  151. expect(result.stderr).toContain('imports "./missing.js"')
  152. expect(result.stderr).toContain('imports "./missing.css"')
  153. })
  154. })