node-half.spec.ts 5.5 KB

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