node-half.client.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. /** Node-half composition diagnostics for package metadata and built client bundles. */
  2. import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import type { IncomingMessage, ServerResponse } from 'node:http'
  4. import { SourceMap } from 'node:module'
  5. import { tmpdir } from 'node:os'
  6. import { dirname, join } from 'node:path'
  7. import { pathToFileURL } from 'node:url'
  8. import { runInNewContext } from 'node:vm'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { afterEach, describe, expect, it } from 'vitest'
  11. import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
  12. import * as modulesClient from '../src/client/index.ts'
  13. import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
  14. import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
  15. const MODULES_ID = '@deepseek-ai/dsh-client-modules'
  16. const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
  17. const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/boot/client.js'
  18. const APPLICATION_URL = '/plugins/_batch/application/app/client.js'
  19. let root: string | undefined
  20. afterEach(() => {
  21. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  22. root = undefined
  23. })
  24. /** Create a resolvable package whose client export points at the returned path. */
  25. function writePackage(
  26. packageName: string,
  27. metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
  28. ): string {
  29. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  30. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  31. const clientPath = join(pkgRoot, 'lib', 'client.js')
  32. mkdirSync(pkgRoot, { recursive: true })
  33. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  34. name: packageName,
  35. exports: {
  36. './client': './lib/client.js',
  37. './package.json': './package.json',
  38. },
  39. ...metadata,
  40. }))
  41. return clientPath
  42. }
  43. /** Create a built package with the supplied client declaration. */
  44. function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
  45. const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
  46. mkdirSync(dirname(clientPath), { recursive: true })
  47. writeFileSync(clientPath, 'module.exports = {}\n')
  48. }
  49. /** Construct the node-half service and capture its plugin-bundle route. */
  50. function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
  51. const ctx = new Context()
  52. ctx.baseUrl = pathToFileURL(root!).href + '/'
  53. ctx.provide('loader', {
  54. *entries() {
  55. for (const packageName of packageNames) {
  56. yield { options: { name: packageName }, fiber: {}, disabled: false }
  57. }
  58. },
  59. })
  60. let route: WebRoute | undefined
  61. const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
  62. port: 0,
  63. register: (candidate) => {
  64. if (candidate.path === '/plugins') route = candidate
  65. return () => {}
  66. },
  67. tapIndex: () => () => {},
  68. }
  69. ctx.provide('webServer', webServer as WebServer)
  70. const service = new ClientModuleRegistry(ctx)
  71. if (route === undefined) throw new Error('client bundle route was not registered')
  72. return { service, route }
  73. }
  74. /** Construct the node-half service over the enabled fixture entries. */
  75. function construct(packageNames: string[]): ClientModuleRegistry {
  76. return constructWithRoute(packageNames).service
  77. }
  78. /** Invoke the registered plugin route and capture status, headers, and bytes. */
  79. async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
  80. status: number
  81. headers: Record<string, string> | undefined
  82. body: Buffer
  83. }> {
  84. let status = 0
  85. let headers: Record<string, string> | undefined
  86. let body = Buffer.alloc(0)
  87. const response = {
  88. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  89. status = nextStatus
  90. headers = nextHeaders
  91. return response
  92. },
  93. end(chunk?: Uint8Array) {
  94. body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
  95. return response
  96. },
  97. } as unknown as ServerResponse
  98. await route.handler({ method, url } as IncomingMessage, response)
  99. return { status, headers, body }
  100. }
  101. /** Execute the exact first inline script emitted by the Host boot rows. */
  102. function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
  103. const html = renderIndexInjections(
  104. '<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
  105. bootInjections(graph),
  106. )
  107. const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
  108. if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
  109. const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
  110. runInNewContext(source, { window })
  111. if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
  112. return { html, target: window.__ModuleLoader__ }
  113. }
  114. const bootGraph = (): WebBootGraph => ({
  115. rev: 'graph',
  116. entries: [
  117. { id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
  118. { id: UI_RENDERER_ID, url: '/plugins/ui-renderer.js?rev=r', rev: 'r' },
  119. ],
  120. batches: [
  121. {
  122. phase: 'bootstrap',
  123. url: BOOTSTRAP_URL,
  124. rev: 'boot',
  125. entries: [MODULES_ID],
  126. },
  127. {
  128. phase: 'application',
  129. url: APPLICATION_URL,
  130. rev: 'app',
  131. entries: [UI_RENDERER_ID],
  132. },
  133. ],
  134. })
  135. describe('HTML bootstrap facade', () => {
  136. it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
  137. const graph = bootGraph()
  138. const { html, target } = injectedFacade(graph)
  139. const facadeAt = html.indexOf('window.__ModuleLoader__=')
  140. const applicationAt = html.indexOf(
  141. `<link rel="preload" as="script" href="${APPLICATION_URL}">`,
  142. )
  143. const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL}"></script>`)
  144. const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
  145. const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
  146. expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
  147. facadeAt, applicationAt, bootstrapAt, graphAt, entryAt,
  148. ])].sort((a, b) => a - b))
  149. target.load({ id: MODULES_ID, factory: () => modulesClient })
  150. const system = target.create({
  151. boot: graph,
  152. staticModules: {},
  153. loadBundle: async (url) => {
  154. expect(url).toBe(APPLICATION_URL)
  155. target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
  156. },
  157. })
  158. expect(target.mode).toBe('live')
  159. expect(target.pendingQueue).toEqual([])
  160. expect(system.manifest.rev).toBe('graph')
  161. expect(await system.import(MODULES_ID)).toBe(modulesClient)
  162. expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' })
  163. expect(() => target.create({ boot: graph, staticModules: {} }))
  164. .toThrow('create called after module-system boot')
  165. })
  166. it('rejects a page that did not preload the modules bundle', () => {
  167. const graph = bootGraph()
  168. const { target } = injectedFacade(graph)
  169. expect(() => target.create({ boot: graph, staticModules: {} }))
  170. .toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
  171. })
  172. it('rejects a bootstrap bundle with a runtime external', () => {
  173. const graph = bootGraph()
  174. const { target } = injectedFacade(graph)
  175. target.load({
  176. id: MODULES_ID,
  177. factory: (require) => {
  178. require('react')
  179. return modulesClient
  180. },
  181. })
  182. expect(() => target.create({ boot: graph, staticModules: {} }))
  183. .toThrow(`${MODULES_ID}/client.js requested external "react"`)
  184. })
  185. it.each([
  186. null,
  187. { ...modulesClient, createClientModuleSystem: undefined },
  188. { ...modulesClient, apply: undefined },
  189. ])('rejects a bootstrap bundle without the complete module face', (exports) => {
  190. const graph = bootGraph()
  191. const { target } = injectedFacade(graph)
  192. target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
  193. expect(() => target.create({ boot: graph, staticModules: {} }))
  194. .toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
  195. })
  196. })
  197. describe('client bundle activation', () => {
  198. it('allows sibling dsh roles', () => {
  199. const currentName = '@fixture/current-client-field'
  200. const clientPath = writePackage(currentName, {
  201. dsh: {
  202. bundle: { patch: './cordis.patch.yml' },
  203. client: { platform: 'web' },
  204. profile: { bundles: [] },
  205. },
  206. })
  207. mkdirSync(dirname(clientPath), { recursive: true })
  208. writeFileSync(clientPath, 'module.exports = {}\n')
  209. expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
  210. })
  211. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  212. const firstName = '@fixture/missing-first'
  213. const secondName = '@fixture/missing-second'
  214. const firstPath = writePackage(firstName)
  215. const secondPath = writePackage(secondName)
  216. expect(() => construct([firstName, secondName])).toThrow([
  217. 'client-modules: 2 client packages failed to compose:',
  218. ' client bundles not found; run `pnpm run build` before launch:',
  219. ` - package: ${firstName}`,
  220. ` path: ${firstPath}`,
  221. ` - package: ${secondName}`,
  222. ` path: ${secondPath}`,
  223. ].join('\n'))
  224. })
  225. it('does not report other bundle read failures as missing builds', () => {
  226. const packageName = '@fixture/unreadable-client'
  227. const clientPath = writePackage(packageName)
  228. mkdirSync(clientPath, { recursive: true })
  229. let thrown: unknown
  230. try {
  231. construct([packageName])
  232. } catch (error) {
  233. thrown = error
  234. }
  235. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  236. expect(String(thrown)).toContain(' other failures:')
  237. expect(String(thrown)).toContain('EISDIR')
  238. expect(String(thrown)).not.toContain('pnpm run build')
  239. })
  240. it('omits a torn or malformed source map without blocking composition', async () => {
  241. const packageName = '@fixture/malformed-source-map'
  242. const clientPath = writePackage(packageName)
  243. mkdirSync(dirname(clientPath), { recursive: true })
  244. writeFileSync(clientPath, 'module.exports = {}\n')
  245. writeFileSync(`${clientPath}.map`, '{')
  246. const torn = constructWithRoute([packageName])
  247. const tornRow = torn.service.graph().entries[0]!
  248. expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
  249. .not.toContain('sourceMappingURL')
  250. expect((await routeRequest(torn.route, `${torn.service.graph().batches[0]!.url}.map`)).status).toBe(404)
  251. writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
  252. expect(() => construct([packageName])).not.toThrow()
  253. })
  254. it('retains one prior immutable batch generation across rebuild recomposition', async () => {
  255. const packageName = '@fixture/batch-rebuild-race'
  256. const clientPath = writePackage(packageName)
  257. mkdirSync(dirname(clientPath), { recursive: true })
  258. writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
  259. const { service, route } = constructWithRoute([packageName])
  260. const first = service.graph().batches[0]!.url
  261. const firstSize = service.artifactBaseline(packageName)!.size
  262. writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
  263. service.rebuilt(packageName)
  264. const second = service.graph().batches[0]!.url
  265. expect(second).not.toBe(first)
  266. expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
  267. expect((await routeRequest(route, first)).status).toBe(200)
  268. expect((await routeRequest(route, second)).status).toBe(200)
  269. writeFileSync(clientPath, 'module.exports = { generation: 3 }\n')
  270. service.rebuilt(packageName)
  271. const third = service.graph().batches[0]!.url
  272. expect((await routeRequest(route, first)).status).toBe(404)
  273. expect((await routeRequest(route, second)).status).toBe(200)
  274. expect((await routeRequest(route, third)).status).toBe(200)
  275. })
  276. it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
  277. const firstName = '@fixture/startup-revision-first'
  278. const secondName = '@fixture/startup-revision-second'
  279. writeBuiltPackage(firstName, {})
  280. writeBuiltPackage(secondName, {})
  281. const service = construct([firstName, secondName])
  282. const [first, second] = service.graph().entries
  283. const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
  284. const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
  285. expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
  286. expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
  287. const firstPath = service.clientPath(firstName)!
  288. const firstStat = statSync(firstPath)
  289. expect(service.artifactBaseline(firstName)).toEqual({
  290. path: firstPath,
  291. mtimeMs: firstStat.mtimeMs,
  292. size: firstStat.size,
  293. mapMtimeMs: null,
  294. mapSize: null,
  295. })
  296. expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
  297. })
  298. it('serves the source map beside a registered client bundle', async () => {
  299. const packageName = '@fixture/source-map'
  300. const clientPath = writePackage(packageName)
  301. mkdirSync(dirname(clientPath), { recursive: true })
  302. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  303. const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n'
  304. writeFileSync(`${clientPath}.map`, map)
  305. const { service, route } = constructWithRoute([packageName])
  306. const row = service.graph().entries[0]!
  307. const individualScript = await routeRequest(route, row.url)
  308. expect(individualScript.body.toString('utf8')).toContain(`sourceMappingURL=client.js.map?rev=${row.rev}`)
  309. const individual = await routeRequest(route, row.url.replace('/client.js?', '/client.js.map?'))
  310. expect(individual.status).toBe(200)
  311. expect(individual.headers).toEqual({
  312. 'content-type': 'application/json; charset=utf-8',
  313. 'cache-control': 'public, max-age=31536000, immutable',
  314. })
  315. expect(individual.body.toString('utf8')).toBe(map)
  316. const batch = service.graph().batches[0]!
  317. expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
  318. const batchScript = await routeRequest(route, batch.url)
  319. expect(batchScript.status).toBe(200)
  320. expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
  321. expect(batchScript.body.toString('utf8')).toContain('//# sourceMappingURL=client.js.map')
  322. expect(batchScript.body.toString('utf8')).not.toContain('sourceMappingURL=client.js.map?rev=')
  323. expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
  324. expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
  325. const batchMap = await routeRequest(route, `${batch.url}.map`)
  326. const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
  327. const parsedIndividualMap = JSON.parse(map) as Record<string, unknown>
  328. expect(parsedBatchMap).toMatchObject({
  329. version: 3,
  330. file: 'client.js',
  331. sections: [{
  332. offset: { line: 0, column: 0 },
  333. map: {
  334. ...parsedIndividualMap,
  335. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  336. },
  337. }],
  338. })
  339. expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
  340. writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
  341. const nextRev = service.rebuilt(packageName)
  342. expect(nextRev).not.toBe(row.rev)
  343. const nextMap = await routeRequest(route, `/plugins/${packageName}/client.js.map?rev=${String(nextRev)}`)
  344. expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({ sources: ['src/changed.tsx'] })
  345. })
  346. it('applies sourceRoot before relocating absolute-looking section sources', async () => {
  347. const packageName = '@fixture/source-root'
  348. const clientPath = writePackage(packageName)
  349. mkdirSync(dirname(clientPath), { recursive: true })
  350. writeFileSync(clientPath, 'module.exports = {}\n')
  351. writeFileSync(`${clientPath}.map`, JSON.stringify({
  352. version: 3,
  353. names: [],
  354. mappings: 'AAAA',
  355. sourceRoot: '../root',
  356. sources: ['/absolute.ts'],
  357. }))
  358. const { service, route } = constructWithRoute([packageName])
  359. const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
  360. const map = JSON.parse(response.body.toString('utf8')) as {
  361. sections: { map: { sourceRoot?: string; sources: string[] } }[]
  362. }
  363. expect(map.sections[0]?.map).toMatchObject({
  364. sources: ['/plugins/@fixture/root/absolute.ts'],
  365. })
  366. expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot')
  367. })
  368. it('maps a non-zero second batch section through a standard source-map consumer', async () => {
  369. const firstName = '@fixture/offset-first'
  370. const secondName = '@fixture/offset-second'
  371. const firstPath = writePackage(firstName)
  372. const secondPath = writePackage(secondName)
  373. for (const [path, source] of [
  374. [firstPath, '../../../packages/demo/first.ts'],
  375. [secondPath, '../../../packages/demo/second.ts'],
  376. ] as const) {
  377. mkdirSync(dirname(path), { recursive: true })
  378. writeFileSync(path, 'window.first = true\nwindow.second = true\n')
  379. writeFileSync(`${path}.map`, JSON.stringify({
  380. version: 3,
  381. names: [],
  382. mappings: 'AAAA',
  383. sources: [source],
  384. sourcesContent: ['export {}\n'],
  385. }))
  386. }
  387. const { service, route } = constructWithRoute([firstName, secondName])
  388. const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
  389. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  390. const sections = (payload as unknown as {
  391. sections: { offset: { line: number; column: number } }[]
  392. }).sections
  393. expect(sections.map(section => section.offset)).toEqual([
  394. { line: 0, column: 0 },
  395. { line: 3, column: 0 },
  396. ])
  397. const consumer = new SourceMap(payload)
  398. expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
  399. expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
  400. })
  401. it('keeps a later source-map section usable when an earlier bundle has no map', async () => {
  402. const unmappedName = '@fixture/unmapped-first'
  403. const mappedName = '@fixture/mapped-second'
  404. const unmappedPath = writePackage(unmappedName)
  405. const mappedPath = writePackage(mappedName)
  406. mkdirSync(dirname(unmappedPath), { recursive: true })
  407. mkdirSync(dirname(mappedPath), { recursive: true })
  408. writeFileSync(unmappedPath, 'window.unmapped = true\n')
  409. writeFileSync(mappedPath, 'window.mapped = true\n')
  410. writeFileSync(`${mappedPath}.map`, JSON.stringify({
  411. version: 3,
  412. names: [],
  413. mappings: 'AAAA',
  414. sources: ['../../../packages/demo/mapped.ts'],
  415. sourcesContent: ['export {}\n'],
  416. }))
  417. const { service, route } = constructWithRoute([unmappedName, mappedName])
  418. const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
  419. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  420. const consumer = new SourceMap(payload)
  421. expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
  422. })
  423. })
  424. describe('shared module declarations', () => {
  425. it('accepts external requests and carries them onto the graph row', () => {
  426. const packageName = '@fixture/shared-declared'
  427. writeBuiltPackage(packageName, { external: ['react'] })
  428. expect(construct([packageName]).graph().entries).toEqual([{
  429. id: packageName,
  430. url: expect.stringContaining(`/plugins/${packageName}/client.js?rev=`) as unknown as string,
  431. rev: expect.any(String) as unknown as string,
  432. external: ['react'],
  433. }])
  434. })
  435. it('omits external when the package declares no requests', () => {
  436. const packageName = '@fixture/shared-absent'
  437. writeBuiltPackage(packageName, {})
  438. const [row] = construct([packageName]).graph().entries
  439. expect(row).not.toHaveProperty('external')
  440. })
  441. it('rejects a non-array external', () => {
  442. const packageName = '@fixture/external-not-array'
  443. writeBuiltPackage(packageName, { external: 'react' })
  444. expect(() => construct([packageName]))
  445. .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
  446. })
  447. })
  448. describe('module graph order', () => {
  449. const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
  450. ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', ...fields })
  451. const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
  452. it('places every requested package row before its consumers along a chain', () => {
  453. expect(ids(orderByModuleGraph([
  454. entry('ui', { external: ['slots'] }),
  455. entry('slots', { external: ['render'] }),
  456. entry('render'),
  457. ]))).toEqual(['render', 'slots', 'ui'])
  458. })
  459. it('places a shared package row before both arms of a diamond', () => {
  460. expect(ids(orderByModuleGraph([
  461. entry('app', { external: ['left', 'right'] }),
  462. entry('left', { external: ['vendor'] }),
  463. entry('right', { external: ['vendor'] }),
  464. entry('vendor'),
  465. ]))).toEqual(['vendor', 'left', 'right', 'app'])
  466. })
  467. it('resolves a /client request onto the requested package row', () => {
  468. expect(ids(orderByModuleGraph([
  469. entry('ui', { external: ['runtime/client'] }),
  470. entry('runtime'),
  471. ]))).toEqual(['runtime', 'ui'])
  472. })
  473. it('leaves a request no row answers to the static assembly channel', () => {
  474. expect(ids(orderByModuleGraph([
  475. entry('consumer', { external: ['@deepseek-ai/cordis'] }),
  476. entry('other'),
  477. ]))).toEqual(['consumer', 'other'])
  478. })
  479. it('rejects a cycle and names the packages on it', () => {
  480. expect(() => orderByModuleGraph([
  481. entry('a', { external: ['b'] }),
  482. entry('b', { external: ['a'] }),
  483. ])).toThrow('client-modules: module graph cycle a -> b -> a')
  484. })
  485. it('rejects a row requesting its own package name', () => {
  486. expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
  487. .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
  488. })
  489. it('composes the served graph in module-graph order', () => {
  490. const consumerName = '@fixture/order-consumer'
  491. const dependencyName = '@fixture/order-dependency'
  492. writeBuiltPackage(consumerName, { external: [dependencyName] })
  493. writeBuiltPackage(dependencyName, {})
  494. expect(ids(construct([consumerName, dependencyName]).graph().entries))
  495. .toEqual([dependencyName, consumerName])
  496. })
  497. it('fails activation loud when scanned packages form a module cycle', () => {
  498. writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
  499. writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
  500. expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
  501. .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
  502. })
  503. })