node-half.client.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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 { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
  10. import { ClientModuleRegistry, orderByModuleGraph } from '../src/index.ts'
  11. import type { WebBootEntry } from '../src/index.ts'
  12. let root: string | undefined
  13. afterEach(() => {
  14. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  15. root = undefined
  16. })
  17. /** Create a resolvable package whose client export points at the returned path. */
  18. function writePackage(
  19. packageName: string,
  20. metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
  21. ): string {
  22. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  23. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  24. const clientPath = join(pkgRoot, 'lib', 'client.js')
  25. mkdirSync(pkgRoot, { recursive: true })
  26. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  27. name: packageName,
  28. exports: {
  29. './client': './lib/client.js',
  30. './package.json': './package.json',
  31. },
  32. ...metadata,
  33. }))
  34. return clientPath
  35. }
  36. /** Create a built package with the supplied client declaration. */
  37. function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
  38. const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
  39. mkdirSync(dirname(clientPath), { recursive: true })
  40. writeFileSync(clientPath, 'module.exports = {}\n')
  41. }
  42. /** Construct the node-half service and capture its plugin-bundle route. */
  43. function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
  44. const ctx = new Context()
  45. ctx.baseUrl = pathToFileURL(root!).href + '/'
  46. ctx.provide('loader', {
  47. *entries() {
  48. for (const packageName of packageNames) {
  49. yield { options: { name: packageName }, fiber: {}, disabled: false }
  50. }
  51. },
  52. })
  53. let route: WebRoute | undefined
  54. const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
  55. port: 0,
  56. register: (candidate) => {
  57. if (candidate.path === '/plugins') route = candidate
  58. return () => {}
  59. },
  60. tapIndex: () => () => {},
  61. }
  62. ctx.provide('webServer', webServer as WebServer)
  63. const service = new ClientModuleRegistry(ctx)
  64. if (route === undefined) throw new Error('client bundle route was not registered')
  65. return { service, route }
  66. }
  67. /** Construct the node-half service over the enabled fixture entries. */
  68. function construct(packageNames: string[]): ClientModuleRegistry {
  69. return constructWithRoute(packageNames).service
  70. }
  71. describe('client bundle activation', () => {
  72. it('allows sibling dsh roles', () => {
  73. const currentName = '@fixture/current-client-field'
  74. const clientPath = writePackage(currentName, {
  75. dsh: {
  76. bundle: { patch: './cordis.patch.yml' },
  77. client: { platform: 'web' },
  78. profile: { bundles: [] },
  79. },
  80. })
  81. mkdirSync(dirname(clientPath), { recursive: true })
  82. writeFileSync(clientPath, 'module.exports = {}\n')
  83. expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
  84. })
  85. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  86. const firstName = '@fixture/missing-first'
  87. const secondName = '@fixture/missing-second'
  88. const firstPath = writePackage(firstName)
  89. const secondPath = writePackage(secondName)
  90. expect(() => construct([firstName, secondName])).toThrow([
  91. 'client-modules: 2 client packages failed to compose:',
  92. ' client bundles not found; run `pnpm run build` before launch:',
  93. ` - package: ${firstName}`,
  94. ` path: ${firstPath}`,
  95. ` - package: ${secondName}`,
  96. ` path: ${secondPath}`,
  97. ].join('\n'))
  98. })
  99. it('does not report other bundle read failures as missing builds', () => {
  100. const packageName = '@fixture/unreadable-client'
  101. const clientPath = writePackage(packageName)
  102. mkdirSync(clientPath, { recursive: true })
  103. let thrown: unknown
  104. try {
  105. construct([packageName])
  106. } catch (error) {
  107. thrown = error
  108. }
  109. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  110. expect(String(thrown)).toContain(' other failures:')
  111. expect(String(thrown)).toContain('EISDIR')
  112. expect(String(thrown)).not.toContain('pnpm run build')
  113. })
  114. it('serves the source map beside a registered client bundle', async () => {
  115. const packageName = '@fixture/source-map'
  116. const clientPath = writePackage(packageName)
  117. mkdirSync(dirname(clientPath), { recursive: true })
  118. writeFileSync(clientPath, 'module.exports = {}\n')
  119. const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
  120. writeFileSync(`${clientPath}.map`, map)
  121. const { route } = constructWithRoute([packageName])
  122. let status = 0
  123. let headers: Record<string, string> | undefined
  124. let body = ''
  125. const response = {
  126. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  127. status = nextStatus
  128. headers = nextHeaders
  129. return response
  130. },
  131. end(chunk?: Uint8Array) {
  132. body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
  133. return response
  134. },
  135. } as unknown as ServerResponse
  136. await route.handler({
  137. method: 'GET',
  138. url: `/plugins/${packageName}/client.js.map`,
  139. } as IncomingMessage, response)
  140. expect(status).toBe(200)
  141. expect(headers).toEqual({
  142. 'content-type': 'application/json; charset=utf-8',
  143. 'cache-control': 'no-cache',
  144. })
  145. expect(body).toBe(map)
  146. })
  147. })
  148. describe('shared module declarations', () => {
  149. it('accepts external requests and carries them onto the graph row', () => {
  150. const packageName = '@fixture/shared-declared'
  151. writeBuiltPackage(packageName, { external: ['react'] })
  152. expect(construct([packageName]).graph().entries).toEqual([{
  153. id: packageName,
  154. url: expect.stringContaining(`/plugins/${packageName}/client.js?rev=`) as unknown as string,
  155. rev: expect.any(String) as unknown as string,
  156. external: ['react'],
  157. }])
  158. })
  159. it('omits external when the package declares no requests', () => {
  160. const packageName = '@fixture/shared-absent'
  161. writeBuiltPackage(packageName, {})
  162. const [row] = construct([packageName]).graph().entries
  163. expect(row).not.toHaveProperty('external')
  164. })
  165. it('rejects a non-array external', () => {
  166. const packageName = '@fixture/external-not-array'
  167. writeBuiltPackage(packageName, { external: 'react' })
  168. expect(() => construct([packageName]))
  169. .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
  170. })
  171. })
  172. describe('module graph order', () => {
  173. const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
  174. ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', ...fields })
  175. const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
  176. it('places every requested package row before its consumers along a chain', () => {
  177. expect(ids(orderByModuleGraph([
  178. entry('ui', { external: ['slots'] }),
  179. entry('slots', { external: ['render'] }),
  180. entry('render'),
  181. ]))).toEqual(['render', 'slots', 'ui'])
  182. })
  183. it('places a shared package row before both arms of a diamond', () => {
  184. expect(ids(orderByModuleGraph([
  185. entry('app', { external: ['left', 'right'] }),
  186. entry('left', { external: ['vendor'] }),
  187. entry('right', { external: ['vendor'] }),
  188. entry('vendor'),
  189. ]))).toEqual(['vendor', 'left', 'right', 'app'])
  190. })
  191. it('resolves a /client request onto the requested package row', () => {
  192. expect(ids(orderByModuleGraph([
  193. entry('ui', { external: ['runtime/client'] }),
  194. entry('runtime'),
  195. ]))).toEqual(['runtime', 'ui'])
  196. })
  197. it('leaves a request no row answers to the static assembly channel', () => {
  198. expect(ids(orderByModuleGraph([
  199. entry('consumer', { external: ['@deepseek-ai/cordis'] }),
  200. entry('other'),
  201. ]))).toEqual(['consumer', 'other'])
  202. })
  203. it('rejects a cycle and names the packages on it', () => {
  204. expect(() => orderByModuleGraph([
  205. entry('a', { external: ['b'] }),
  206. entry('b', { external: ['a'] }),
  207. ])).toThrow('client-modules: module graph cycle a -> b -> a')
  208. })
  209. it('rejects a row requesting its own package name', () => {
  210. expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
  211. .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
  212. })
  213. it('composes the served graph in module-graph order', () => {
  214. const consumerName = '@fixture/order-consumer'
  215. const dependencyName = '@fixture/order-dependency'
  216. writeBuiltPackage(consumerName, { external: [dependencyName] })
  217. writeBuiltPackage(dependencyName, {})
  218. expect(ids(construct([consumerName, dependencyName]).graph().entries))
  219. .toEqual([dependencyName, consumerName])
  220. })
  221. it('fails activation loud when scanned packages form a module cycle', () => {
  222. writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
  223. writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
  224. expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
  225. .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
  226. })
  227. })