node-half.spec.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. /** Node-half composition diagnostics for package metadata and built client bundles. */
  2. import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  3. import type { IncomingMessage, ServerResponse } from 'node:http'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { Context } from 'cordis'
  8. import { afterEach, describe, expect, it } from 'vitest'
  9. import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
  10. import { ClientModuleHostService } from '../src/index.ts'
  11. let root: string | undefined
  12. afterEach(() => {
  13. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  14. root = undefined
  15. })
  16. /** Create a resolvable dshClient package whose client export points at the returned path. */
  17. function writePackage(packageName: string): string {
  18. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  19. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  20. const clientPath = join(pkgRoot, 'lib', 'client.js')
  21. mkdirSync(pkgRoot, { recursive: true })
  22. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  23. name: packageName,
  24. exports: {
  25. './client': './lib/client.js',
  26. './package.json': './package.json',
  27. },
  28. dshClient: { platform: 'web' },
  29. }))
  30. return clientPath
  31. }
  32. /** Construct the node-half service and capture its plugin-bundle route. */
  33. function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
  34. const ctx = new Context()
  35. ctx.baseUrl = pathToFileURL(root!).href + '/'
  36. ctx.provide('loader', {
  37. *entries() {
  38. for (const packageName of packageNames) {
  39. yield { options: { name: packageName }, fiber: {}, disabled: false }
  40. }
  41. },
  42. })
  43. let route: WebRoute | undefined
  44. const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
  45. port: 0,
  46. register: (candidate) => {
  47. if (candidate.path === '/plugins') route = candidate
  48. return () => {}
  49. },
  50. tapIndex: () => () => {},
  51. }
  52. ctx.provide('httpServer', httpServer as HttpServerService)
  53. const service = new ClientModuleHostService(ctx)
  54. if (route === undefined) throw new Error('client bundle route was not registered')
  55. return { service, route }
  56. }
  57. /** Construct the node-half service over the enabled fixture entries. */
  58. function construct(packageNames: string[]): ClientModuleHostService {
  59. return constructWithRoute(packageNames).service
  60. }
  61. describe('client bundle activation', () => {
  62. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  63. const firstName = '@fixture/missing-first'
  64. const secondName = '@fixture/missing-second'
  65. const firstPath = writePackage(firstName)
  66. const secondPath = writePackage(secondName)
  67. expect(() => construct([firstName, secondName])).toThrow([
  68. 'client-modules: 2 client packages failed to compose:',
  69. ' client bundles not found; run `pnpm run build` before launch:',
  70. ` - package: ${firstName}`,
  71. ` path: ${firstPath}`,
  72. ` - package: ${secondName}`,
  73. ` path: ${secondPath}`,
  74. ].join('\n'))
  75. })
  76. it('does not report other bundle read failures as missing builds', () => {
  77. const packageName = '@fixture/unreadable-client'
  78. const clientPath = writePackage(packageName)
  79. mkdirSync(clientPath, { recursive: true })
  80. let thrown: unknown
  81. try {
  82. construct([packageName])
  83. } catch (error) {
  84. thrown = error
  85. }
  86. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  87. expect(String(thrown)).toContain(' other failures:')
  88. expect(String(thrown)).toContain('EISDIR')
  89. expect(String(thrown)).not.toContain('pnpm run build')
  90. })
  91. it('serves the source map beside a registered client bundle', async () => {
  92. const packageName = '@fixture/source-map'
  93. const clientPath = writePackage(packageName)
  94. mkdirSync(dirname(clientPath), { recursive: true })
  95. writeFileSync(clientPath, 'module.exports = {}\n')
  96. const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
  97. writeFileSync(`${clientPath}.map`, map)
  98. const { route } = constructWithRoute([packageName])
  99. let status = 0
  100. let headers: Record<string, string> | undefined
  101. let body = ''
  102. const response = {
  103. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  104. status = nextStatus
  105. headers = nextHeaders
  106. return response
  107. },
  108. end(chunk?: Uint8Array) {
  109. body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
  110. return response
  111. },
  112. } as unknown as ServerResponse
  113. await route.handler({
  114. method: 'GET',
  115. url: `/plugins/${packageName}/client.js.map`,
  116. } as IncomingMessage, response)
  117. expect(status).toBe(200)
  118. expect(headers).toEqual({
  119. 'content-type': 'application/json; charset=utf-8',
  120. 'cache-control': 'no-cache',
  121. })
  122. expect(body).toBe(map)
  123. })
  124. })