node-half.client.spec.ts 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  1. /** Node-half composition diagnostics for package metadata and built client bundles. */
  2. import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, utimesSync, 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, FiberState, type Fiber } from '@deepseek-ai/cordis'
  10. import { afterEach, describe, expect, it, vi } 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 comboUrl = (ids: readonly string[], rev: string): string =>
  18. `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
  19. const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
  20. const chunkUrl = (id: string, fileName: string, rev: string): string => `/plugins/${id}/${fileName}?rev=${rev}`
  21. const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
  22. const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
  23. let root: string | undefined
  24. const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
  25. afterEach(async () => {
  26. await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
  27. await ready
  28. await ctx.fiber.dispose()
  29. }))
  30. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  31. root = undefined
  32. })
  33. it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
  34. const ctx = new Context()
  35. contexts.push({ ctx })
  36. ctx.provide('loader', { entries: () => [] })
  37. const routes = new Set<WebRoute>()
  38. const mountServer = () => ctx.plugin((serverCtx) => {
  39. serverCtx.provide('webServer', {
  40. register: (route: WebRoute) => {
  41. routes.add(route)
  42. return () => { routes.delete(route) }
  43. },
  44. } as WebServer)
  45. })
  46. let server = serverFirst ? await mountServer() : undefined
  47. const modules = await ctx.plugin(ClientModuleRegistry)
  48. const service = ctx.get('clientModules')!
  49. expect(service.graph().entries).toEqual([])
  50. expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
  51. if (!serverFirst) {
  52. expect(routes.size).toBe(0)
  53. server = await mountServer()
  54. }
  55. await expect.poll(() => routes.size).toBe(1)
  56. await server!.dispose()
  57. await expect.poll(() => routes.size).toBe(0)
  58. expect(modules.state).toBe(FiberState.ACTIVE)
  59. expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
  60. await mountServer()
  61. await expect.poll(() => routes.size).toBe(1)
  62. await modules.dispose()
  63. expect(routes.size).toBe(0)
  64. })
  65. /** Create a resolvable package whose client export points at the returned path. */
  66. function writePackage(
  67. packageName: string,
  68. metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
  69. ): string {
  70. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  71. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  72. const clientPath = join(pkgRoot, 'lib', 'client.js')
  73. mkdirSync(pkgRoot, { recursive: true })
  74. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  75. name: packageName,
  76. exports: {
  77. './client': './lib/client.js',
  78. './package.json': './package.json',
  79. },
  80. ...metadata,
  81. }))
  82. return clientPath
  83. }
  84. /** Create a built package with the supplied client declaration. */
  85. function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
  86. const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
  87. mkdirSync(dirname(clientPath), { recursive: true })
  88. writeFileSync(clientPath, 'module.exports = {}\n')
  89. }
  90. /** Construct the node-half service and capture its plugin-bundle route. */
  91. function constructWithRoute(
  92. packageNames: string[],
  93. options: {
  94. contextBaseUrl?: string
  95. entryBaseUrl?: string
  96. internal?: NonNullable<Context['loader']['internal']>
  97. } = {},
  98. ): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
  99. const ctx = new Context()
  100. const owned: typeof contexts[number] = { ctx }
  101. contexts.push(owned)
  102. ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
  103. ctx.provide('loader', {
  104. internal: options.internal,
  105. *entries() {
  106. for (const packageName of packageNames) {
  107. yield {
  108. options: { name: packageName },
  109. fiber: {},
  110. disabled: false,
  111. parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } },
  112. }
  113. }
  114. },
  115. })
  116. const route = Promise.withResolvers<WebRoute>()
  117. const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
  118. port: 0,
  119. register: (candidate) => {
  120. if (candidate.path === '/plugins') route.resolve(candidate)
  121. return () => {}
  122. },
  123. tapIndex: () => () => {},
  124. }
  125. ctx.provide('webServer', webServer as WebServer)
  126. const service = new ClientModuleRegistry(ctx)
  127. owned.ready = route.promise
  128. return { context: ctx, service, route: route.promise }
  129. }
  130. /** Construct the node-half service over the enabled fixture entries. */
  131. function construct(packageNames: string[]): ClientModuleRegistry {
  132. return constructWithRoute(packageNames).service
  133. }
  134. /** Invoke the registered plugin route and capture status, headers, and bytes. */
  135. async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
  136. status: number
  137. headers: Record<string, string> | undefined
  138. body: Buffer
  139. }> {
  140. let status = 0
  141. let headers: Record<string, string> | undefined
  142. let body = Buffer.alloc(0)
  143. const response = {
  144. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  145. status = nextStatus
  146. headers = nextHeaders
  147. return response
  148. },
  149. end(chunk?: Uint8Array) {
  150. body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
  151. return response
  152. },
  153. } as unknown as ServerResponse
  154. await (await route).handler({ method, url } as IncomingMessage, response)
  155. return { status, headers, body }
  156. }
  157. /** Execute the exact first inline script emitted by the Host boot rows. */
  158. function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
  159. const html = renderIndexInjections(
  160. '<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
  161. bootInjections(graph),
  162. )
  163. const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
  164. if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
  165. const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
  166. runInNewContext(source, { window })
  167. if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
  168. return { html, target: window.__ModuleLoader__ }
  169. }
  170. const bootGraph = (): WebBootGraph => ({
  171. rev: 'graph',
  172. entries: [
  173. { id: MODULES_ID, url: comboUrl([MODULES_ID], 'm'), rev: 'm' },
  174. { id: UI_RENDERER_ID, url: comboUrl([UI_RENDERER_ID], 'r'), rev: 'r' },
  175. ],
  176. batches: [
  177. {
  178. phase: 'bootstrap',
  179. url: BOOTSTRAP_URL,
  180. rev: 'boot',
  181. entries: [MODULES_ID],
  182. },
  183. {
  184. phase: 'application',
  185. url: APPLICATION_URL,
  186. rev: 'app',
  187. entries: [UI_RENDERER_ID],
  188. },
  189. ],
  190. })
  191. describe('HTML bootstrap facade', () => {
  192. it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
  193. const graph = bootGraph()
  194. const { html, target } = injectedFacade(graph)
  195. const facadeAt = html.indexOf('window.__ModuleLoader__=')
  196. const applicationAt = html.indexOf(
  197. `<link rel="preload" as="script" href="${APPLICATION_URL.replaceAll('&', '&amp;')}">`,
  198. )
  199. const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL.replaceAll('&', '&amp;')}"></script>`)
  200. const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
  201. const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
  202. expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
  203. facadeAt, applicationAt, bootstrapAt, graphAt, entryAt,
  204. ])].sort((a, b) => a - b))
  205. target.load({ id: MODULES_ID, factory: () => modulesClient })
  206. const system = target.create({
  207. boot: graph,
  208. staticModules: {},
  209. loadBundle: async (url) => {
  210. expect(url).toBe(APPLICATION_URL)
  211. target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
  212. },
  213. })
  214. expect(target.mode).toBe('live')
  215. expect(target.pendingQueue).toEqual([])
  216. expect(system.manifest.rev).toBe('graph')
  217. expect(await system.import(MODULES_ID)).toBe(modulesClient)
  218. expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' })
  219. expect(() => target.create({ boot: graph, staticModules: {} }))
  220. .toThrow('create called after module-system boot')
  221. })
  222. it('preloads every application combo', () => {
  223. const graph = bootGraph()
  224. const secondId = '@fixture/second-application-combo'
  225. const secondUrl = comboUrl([secondId], 'app-2')
  226. graph.entries.push({ id: secondId, url: comboUrl([secondId], 'row-2'), rev: 'row-2' })
  227. graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
  228. expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
  229. .toEqual([APPLICATION_URL, secondUrl])
  230. })
  231. it('rejects a page that did not preload the modules bundle', () => {
  232. const graph = bootGraph()
  233. const { target } = injectedFacade(graph)
  234. expect(() => target.create({ boot: graph, staticModules: {} }))
  235. .toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
  236. })
  237. it('rejects a bootstrap bundle with a runtime external', () => {
  238. const graph = bootGraph()
  239. const { target } = injectedFacade(graph)
  240. target.load({
  241. id: MODULES_ID,
  242. factory: (require) => {
  243. require('react')
  244. return modulesClient
  245. },
  246. })
  247. expect(() => target.create({ boot: graph, staticModules: {} }))
  248. .toThrow(`${MODULES_ID}/client.js requested external "react"`)
  249. })
  250. it.each([
  251. null,
  252. { ...modulesClient, createClientModuleSystem: undefined },
  253. { ...modulesClient, apply: undefined },
  254. ])('rejects a bootstrap bundle without the complete module face', (exports) => {
  255. const graph = bootGraph()
  256. const { target } = injectedFacade(graph)
  257. target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
  258. expect(() => target.create({ boot: graph, staticModules: {} }))
  259. .toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
  260. })
  261. })
  262. describe('client bundle activation', () => {
  263. it.each(['v1', 'v2'] as const)(
  264. 'resolves %s package metadata from the owning entry tree',
  265. (version) => {
  266. const packageName = `@fixture/entry-base-${version}`
  267. const clientPath = writePackage(packageName)
  268. const hostPath = join(dirname(clientPath), 'index.js')
  269. mkdirSync(dirname(hostPath), { recursive: true })
  270. writeFileSync(hostPath, 'export default {}\n')
  271. writeFileSync(clientPath, 'module.exports = {}\n')
  272. const contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/'
  273. const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
  274. const calls: unknown[][] = []
  275. const resolveSync = (...args: unknown[]) => {
  276. calls.push(args)
  277. return { format: 'module' as const, url: pathToFileURL(hostPath).href }
  278. }
  279. const internal = { version, resolveSync }
  280. const { service } = constructWithRoute([packageName], {
  281. contextBaseUrl,
  282. entryBaseUrl,
  283. internal: internal as NonNullable<Context['loader']['internal']>,
  284. })
  285. expect(calls).toEqual(version === 'v2'
  286. ? [[entryBaseUrl, { specifier: packageName, attributes: {} }]]
  287. : [[packageName, entryBaseUrl, {}]])
  288. expect(service.clientPath(packageName)).toBe(clientPath)
  289. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  290. },
  291. )
  292. it('derives the browser module id from a file entry owning manifest', () => {
  293. const packageName = '@fixture/file-entry'
  294. const clientPath = writePackage(packageName)
  295. const hostPath = join(dirname(clientPath), 'index.js')
  296. mkdirSync(dirname(hostPath), { recursive: true })
  297. writeFileSync(hostPath, 'export default {}\n')
  298. writeFileSync(clientPath, 'module.exports = {}\n')
  299. const service = construct([pathToFileURL(hostPath).href])
  300. expect(service.clientPath(packageName)).toBe(clientPath)
  301. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  302. })
  303. it.each(['relative', 'absolute'] as const)(
  304. 'finds the owning manifest through the %s-path fallback without Node loader internals',
  305. (kind) => {
  306. const packageName = `@fixture/${kind}-fallback-entry`
  307. const clientPath = writePackage(packageName)
  308. const packageRoot = dirname(dirname(clientPath))
  309. const hostPath = join(packageRoot, 'index.js')
  310. mkdirSync(dirname(clientPath), { recursive: true })
  311. writeFileSync(hostPath, 'export default {}\n')
  312. writeFileSync(clientPath, 'module.exports = {}\n')
  313. const loaderName = kind === 'relative' ? './index.js' : hostPath
  314. const { service } = constructWithRoute([loaderName], {
  315. entryBaseUrl: pathToFileURL(packageRoot).href + '/',
  316. })
  317. expect(service.clientPath(packageName)).toBe(clientPath)
  318. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  319. },
  320. )
  321. it.each(['v1', 'v2', 'worker'] as const)(
  322. 'derives a file entry package id through the %s Loader resolver',
  323. (version) => {
  324. const packageName = `@fixture/file-entry-${version}`
  325. const clientPath = writePackage(packageName)
  326. const hostPath = join(dirname(clientPath), 'index.js')
  327. mkdirSync(dirname(hostPath), { recursive: true })
  328. writeFileSync(hostPath, 'export default {}\n')
  329. writeFileSync(clientPath, 'module.exports = {}\n')
  330. const loaderName = pathToFileURL(hostPath).href
  331. const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
  332. const calls: unknown[][] = []
  333. const resolveSync = (...args: unknown[]) => {
  334. calls.push(args)
  335. return { format: 'module' as const, url: loaderName }
  336. }
  337. const internal = { version, resolveSync }
  338. const { service } = constructWithRoute([loaderName], {
  339. entryBaseUrl,
  340. internal: internal as NonNullable<Context['loader']['internal']>,
  341. })
  342. expect(calls).toEqual(version === 'v2'
  343. ? [[entryBaseUrl, { specifier: loaderName, attributes: {} }]]
  344. : [[loaderName, entryBaseUrl, {}]])
  345. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  346. },
  347. )
  348. it('rejects distinct active Loader sources for one browser package', () => {
  349. const packageName = '@fixture/duplicate-source'
  350. const clientPath = writePackage(packageName)
  351. const hostPath = join(dirname(clientPath), 'index.js')
  352. mkdirSync(dirname(hostPath), { recursive: true })
  353. writeFileSync(hostPath, 'export default {}\n')
  354. writeFileSync(clientPath, 'module.exports = {}\n')
  355. const alias = './duplicate-source.js'
  356. const internal = {
  357. version: 'v2' as const,
  358. resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
  359. }
  360. expect(() => constructWithRoute([packageName, alias], {
  361. internal: internal as unknown as NonNullable<Context['loader']['internal']>,
  362. })).toThrow(
  363. `client-modules: package ${packageName} resolves from multiple active Loader sources:`,
  364. )
  365. })
  366. it('promotes the remaining Loader source after the selected alias unloads', async () => {
  367. const packageName = '@fixture/duplicate-source-recovery'
  368. const clientPath = writePackage(packageName)
  369. const hostPath = join(dirname(clientPath), 'index.js')
  370. mkdirSync(dirname(hostPath), { recursive: true })
  371. writeFileSync(hostPath, 'export default {}\n')
  372. writeFileSync(clientPath, 'module.exports = {}\n')
  373. const alias = './duplicate-source-recovery.js'
  374. const entries = [packageName]
  375. const internal = {
  376. version: 'v2' as const,
  377. resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
  378. }
  379. const { context, service } = constructWithRoute(entries, {
  380. internal: internal as unknown as NonNullable<Context['loader']['internal']>,
  381. })
  382. const firstRevision = service.graph().entries[0]!.rev
  383. const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined)
  384. entries.push(alias)
  385. emitLoaderEntryChange(context, alias)
  386. await Promise.resolve()
  387. expect(warning).toHaveBeenCalledWith(expect.objectContaining({
  388. message: expect.stringContaining(`package ${packageName} resolves from multiple active Loader sources`) as string,
  389. }))
  390. expect(service.graph().entries[0]!.rev).toBe(firstRevision)
  391. entries.splice(entries.indexOf(packageName), 1)
  392. emitLoaderEntryChange(context, packageName)
  393. await Promise.resolve()
  394. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  395. expect(service.graph().entries[0]!.rev).not.toBe(firstRevision)
  396. expect(service.clientPath(packageName)).toBe(clientPath)
  397. })
  398. it('uses owning-tree package resolution for an import-only Worker module loader', () => {
  399. const packageName = '@fixture/worker-loader'
  400. writeBuiltPackage(packageName, {})
  401. const internal = {
  402. version: 'worker',
  403. import: async () => ({}),
  404. } as unknown as NonNullable<Context['loader']['internal']>
  405. const { service } = constructWithRoute([packageName], { internal })
  406. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  407. })
  408. it('allows sibling dsh roles', () => {
  409. const currentName = '@fixture/current-client-field'
  410. const clientPath = writePackage(currentName, {
  411. dsh: {
  412. bundle: { patch: './cordis.patch.yml' },
  413. client: { platform: 'web' },
  414. profile: { bundles: [] },
  415. },
  416. })
  417. mkdirSync(dirname(clientPath), { recursive: true })
  418. writeFileSync(clientPath, 'module.exports = {}\n')
  419. expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
  420. })
  421. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  422. const firstName = '@fixture/missing-first'
  423. const secondName = '@fixture/missing-second'
  424. const firstPath = writePackage(firstName)
  425. const secondPath = writePackage(secondName)
  426. expect(() => construct([firstName, secondName])).toThrow([
  427. 'client-modules: 2 client packages failed to compose:',
  428. ' client bundles not found; run `pnpm run build` before launch:',
  429. ` - package: ${firstName}`,
  430. ` path: ${firstPath}`,
  431. ` - package: ${secondName}`,
  432. ` path: ${secondPath}`,
  433. ].join('\n'))
  434. })
  435. it('does not report other bundle read failures as missing builds', () => {
  436. const packageName = '@fixture/unreadable-client'
  437. const clientPath = writePackage(packageName)
  438. mkdirSync(clientPath, { recursive: true })
  439. let thrown: unknown
  440. try {
  441. construct([packageName])
  442. } catch (error) {
  443. thrown = error
  444. }
  445. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  446. expect(String(thrown)).toContain(' other failures:')
  447. expect(String(thrown)).toContain('EISDIR')
  448. expect(String(thrown)).not.toContain('pnpm run build')
  449. })
  450. it('falls back to a generated-file map when an authored map is malformed', async () => {
  451. const packageName = '@fixture/malformed-source-map'
  452. const clientPath = writePackage(packageName)
  453. mkdirSync(dirname(clientPath), { recursive: true })
  454. writeFileSync(clientPath, 'module.exports = {}\n')
  455. writeFileSync(`${clientPath}.map`, '{')
  456. const torn = constructWithRoute([packageName])
  457. const tornRow = torn.service.graph().entries[0]!
  458. expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
  459. .toContain(`sourceMappingURL=${mapUrl(tornRow.url)}`)
  460. const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
  461. expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
  462. sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
  463. })
  464. writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
  465. expect(() => construct([packageName])).not.toThrow()
  466. writeFileSync(`${clientPath}.map`, JSON.stringify({
  467. version: 3,
  468. names: [],
  469. mappings: 'AAAA',
  470. sourceRoot: 'http://[',
  471. sources: ['src/index.ts'],
  472. }))
  473. const invalidUrl = constructWithRoute([packageName])
  474. const invalidMapUrl = mapUrl(invalidUrl.service.graph().batches[0]!.url)
  475. expect(JSON.parse((await routeRequest(invalidUrl.route, invalidMapUrl)).body.toString('utf8'))).toMatchObject({
  476. sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
  477. })
  478. })
  479. it('reads a source map only on its first map GET', async () => {
  480. const packageName = '@fixture/lazy-source-map'
  481. const clientPath = writePackage(packageName)
  482. mkdirSync(dirname(clientPath), { recursive: true })
  483. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  484. writeFileSync(`${clientPath}.map`, '{')
  485. const { service, route } = constructWithRoute([packageName])
  486. const batch = service.graph().batches[0]!
  487. const sourceMapUrl = mapUrl(batch.url)
  488. const script = await routeRequest(route, batch.url)
  489. expect(script.status).toBe(200)
  490. expect((await routeRequest(route, sourceMapUrl, 'HEAD')).body).toHaveLength(0)
  491. writeFileSync(`${clientPath}.map`, JSON.stringify({
  492. version: 3,
  493. names: [],
  494. mappings: 'AAAA',
  495. sources: ['src/first.ts'],
  496. }))
  497. const first = await routeRequest(route, sourceMapUrl)
  498. expect(JSON.parse(first.body.toString('utf8'))).toMatchObject({
  499. sections: [{ map: { sources: [`/plugins/${packageName}/src/first.ts`] } }],
  500. })
  501. writeFileSync(`${clientPath}.map`, JSON.stringify({
  502. version: 3,
  503. names: [],
  504. mappings: 'AAAA',
  505. sources: ['src/second.ts'],
  506. }))
  507. expect((await routeRequest(route, sourceMapUrl)).body).toEqual(first.body)
  508. expect((await routeRequest(route, batch.url)).body).toEqual(script.body)
  509. })
  510. it('retains a materialized resource when an unrelated row recomposes the graph', async () => {
  511. const stablePackage = '@fixture/stable-source-map'
  512. const rebuiltPackage = '@fixture/rebuilt-neighbor'
  513. const stablePath = writePackage(stablePackage)
  514. const rebuiltPath = writePackage(rebuiltPackage)
  515. for (const clientPath of [stablePath, rebuiltPath]) {
  516. mkdirSync(dirname(clientPath), { recursive: true })
  517. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  518. writeFileSync(`${clientPath}.map`, JSON.stringify({
  519. version: 3,
  520. names: [],
  521. mappings: 'AAAA',
  522. sources: ['src/first.ts'],
  523. }))
  524. }
  525. const { service, route } = constructWithRoute([stablePackage, rebuiltPackage])
  526. const stableUrl = service.graph().entries.find(entry => entry.id === stablePackage)!.url
  527. const stableMapUrl = mapUrl(stableUrl)
  528. const first = await routeRequest(route, stableMapUrl)
  529. const stableChunkPath = join(dirname(stablePath), 'client.stable.js')
  530. writeFileSync(stableChunkPath, 'module.exports = { generation: 1 }\n')
  531. const stableRow = service.graph().entries.find(entry => entry.id === stablePackage)!
  532. const stableChunkUrl = chunkUrl(stablePackage, 'client.stable.js', stableRow.rev)
  533. const firstChunk = await routeRequest(route, stableChunkUrl)
  534. writeFileSync(`${stablePath}.map`, JSON.stringify({
  535. version: 3,
  536. names: [],
  537. mappings: 'AAAA',
  538. sources: ['src/second.ts'],
  539. }))
  540. writeFileSync(stableChunkPath, 'module.exports = { generation: 2 }\n')
  541. writeFileSync(rebuiltPath, 'module.exports = { rebuilt: true }\n')
  542. service.rebuilt(rebuiltPackage)
  543. expect((await routeRequest(route, stableMapUrl)).body).toEqual(first.body)
  544. expect((await routeRequest(route, stableChunkUrl)).body).toEqual(firstChunk.body)
  545. })
  546. it('maps packed combo sections back to each generated client bundle', async () => {
  547. const names = ['@fixture/generated-first', '@fixture/generated-second']
  548. for (const [index, packageName] of names.entries()) {
  549. const clientPath = writePackage(packageName)
  550. mkdirSync(dirname(clientPath), { recursive: true })
  551. writeFileSync(
  552. clientPath,
  553. `window.generation = ${String(index)}\n//# sourceURL=packages/client/generated-${String(index)}/lib/client.js`,
  554. )
  555. }
  556. const { service, route } = constructWithRoute(names)
  557. const batch = service.graph().batches[0]!
  558. const script = (await routeRequest(route, batch.url)).body.toString('utf8')
  559. expect(script).not.toContain('//# sourceURL=')
  560. expect(script).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
  561. const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
  562. sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
  563. }
  564. expect(payload.sections.map(section => section.map)).toEqual([
  565. {
  566. version: 3,
  567. names: [],
  568. mappings: 'AAAA',
  569. sources: ['/packages/client/generated-0/lib/client.js'],
  570. sourcesContent: ['window.generation = 0\n'],
  571. },
  572. {
  573. version: 3,
  574. names: [],
  575. mappings: 'AAAA',
  576. sources: ['/packages/client/generated-1/lib/client.js'],
  577. sourcesContent: ['window.generation = 1\n'],
  578. },
  579. ])
  580. const consumer = new SourceMap(payload as unknown as ConstructorParameters<typeof SourceMap>[0])
  581. expect(consumer.findEntry(0, 0)).toMatchObject({
  582. originalSource: '/packages/client/generated-0/lib/client.js',
  583. })
  584. expect(consumer.findEntry(2, 0)).toMatchObject({
  585. originalSource: '/packages/client/generated-1/lib/client.js',
  586. })
  587. })
  588. it('retains one prior immutable batch generation across rebuild recomposition', async () => {
  589. const packageName = '@fixture/batch-rebuild-race'
  590. const clientPath = writePackage(packageName)
  591. mkdirSync(dirname(clientPath), { recursive: true })
  592. writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
  593. const { service, route } = constructWithRoute([packageName])
  594. const first = service.graph().batches[0]!.url
  595. const firstSize = service.artifactBaseline(packageName)!.size
  596. writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
  597. service.rebuilt(packageName)
  598. const second = service.graph().batches[0]!.url
  599. expect(second).not.toBe(first)
  600. expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
  601. expect((await routeRequest(route, first)).status).toBe(200)
  602. expect((await routeRequest(route, second)).status).toBe(200)
  603. writeFileSync(clientPath, 'module.exports = { generation: 3 }\n')
  604. service.rebuilt(packageName)
  605. const third = service.graph().batches[0]!.url
  606. expect((await routeRequest(route, first)).status).toBe(404)
  607. expect((await routeRequest(route, second)).status).toBe(200)
  608. expect((await routeRequest(route, third)).status).toBe(200)
  609. })
  610. it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
  611. const firstName = '@fixture/startup-revision-first'
  612. const secondName = '@fixture/startup-revision-second'
  613. writeBuiltPackage(firstName, {})
  614. writeBuiltPackage(secondName, {})
  615. const service = construct([firstName, secondName])
  616. const [first, second] = service.graph().entries
  617. const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
  618. const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
  619. expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
  620. expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
  621. const firstPath = service.clientPath(firstName)!
  622. const firstStat = statSync(firstPath)
  623. expect(service.artifactBaseline(firstName)).toEqual({
  624. path: firstPath,
  625. mtimeMs: firstStat.mtimeMs,
  626. size: firstStat.size,
  627. })
  628. expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
  629. })
  630. it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
  631. const packageNames = Array.from({ length: 48 }, (_, index) => (
  632. `@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
  633. ))
  634. const sourceMap = JSON.stringify({
  635. version: 3,
  636. names: [],
  637. mappings: 'AAAA',
  638. sources: ['src/index.ts'],
  639. })
  640. for (const packageName of packageNames) {
  641. const clientPath = writePackage(packageName)
  642. mkdirSync(dirname(clientPath), { recursive: true })
  643. writeFileSync(clientPath, 'module.exports = {}\n')
  644. writeFileSync(`${clientPath}.map`, sourceMap)
  645. }
  646. const { service, route } = constructWithRoute(packageNames)
  647. const batches = service.graph().batches.filter(batch => batch.phase === 'application')
  648. expect(batches.length).toBeGreaterThan(1)
  649. expect(batches.flatMap(batch => batch.entries)).toEqual(packageNames)
  650. for (const batch of batches) {
  651. expect(Buffer.byteLength(batch.url)).toBeLessThanOrEqual(3 * 1024)
  652. expect(Buffer.byteLength(mapUrl(batch.url))).toBeLessThanOrEqual(3 * 1024)
  653. expect((await routeRequest(route, batch.url)).status).toBe(200)
  654. expect((await routeRequest(route, mapUrl(batch.url))).status).toBe(200)
  655. }
  656. for (let index = 0; index < batches.length - 1; index += 1) {
  657. const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
  658. expect(Buffer.byteLength(mapUrl(comboUrl(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
  659. }
  660. })
  661. it('serves the source map beside a registered client bundle', async () => {
  662. const packageName = '@fixture/source-map'
  663. const clientPath = writePackage(packageName)
  664. mkdirSync(dirname(clientPath), { recursive: true })
  665. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  666. const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n'
  667. writeFileSync(`${clientPath}.map`, map)
  668. const { service, route } = constructWithRoute([packageName])
  669. const row = service.graph().entries[0]!
  670. const singleScript = await routeRequest(route, row.url)
  671. expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapUrl(row.url)}`)
  672. const singleMap = await routeRequest(route, mapUrl(row.url))
  673. expect(singleMap.status).toBe(200)
  674. expect(singleMap.headers).toEqual({
  675. 'content-type': 'application/json; charset=utf-8',
  676. 'cache-control': 'public, max-age=31536000, immutable',
  677. })
  678. expect(JSON.parse(singleMap.body.toString('utf8'))).toMatchObject({
  679. version: 3,
  680. file: 'client.js',
  681. sections: [{
  682. offset: { line: 0, column: 0 },
  683. map: {
  684. ...(JSON.parse(map) as Record<string, unknown>),
  685. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  686. },
  687. }],
  688. })
  689. const batch = service.graph().batches[0]!
  690. expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
  691. const batchScript = await routeRequest(route, batch.url)
  692. expect(batchScript.status).toBe(200)
  693. expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
  694. expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
  695. const shellResponse = await service.fetchBundle(new Request(`dsh-app://app${batch.url}`))
  696. expect(shellResponse.status).toBe(200)
  697. expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
  698. expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8'))
  699. expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
  700. expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
  701. const batchMap = await routeRequest(route, mapUrl(batch.url))
  702. const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
  703. const parsedPluginMap = JSON.parse(map) as Record<string, unknown>
  704. expect(parsedBatchMap).toMatchObject({
  705. version: 3,
  706. file: 'client.js',
  707. sections: [{
  708. offset: { line: 0, column: 0 },
  709. map: {
  710. ...parsedPluginMap,
  711. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  712. },
  713. }],
  714. })
  715. expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
  716. writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
  717. const nextRev = service.rebuilt(packageName)
  718. expect(nextRev).not.toBe(row.rev)
  719. const nextRow = service.graph().entries[0]!
  720. expect(nextRow.rev).toBe(nextRev)
  721. const nextMap = await routeRequest(route, mapUrl(nextRow.url))
  722. expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({
  723. sections: [{ map: { sources: ['/plugins/@fixture/source-map/src/changed.tsx'] } }],
  724. })
  725. })
  726. it('serves a package-local chunk only after its versioned URL is requested', async () => {
  727. const packageName = '@fixture/chunked'
  728. const clientPath = writePackage(packageName)
  729. const chunkPath = join(dirname(clientPath), 'client.terminal.js')
  730. mkdirSync(dirname(clientPath), { recursive: true })
  731. writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.terminal.js") }\n')
  732. const { service, route } = constructWithRoute([packageName])
  733. const row = service.graph().entries[0]!
  734. const startup = await routeRequest(route, row.url)
  735. expect(startup.status).toBe(200)
  736. expect(startup.body.toString('utf8')).not.toContain('terminal loaded')
  737. expect((await routeRequest(route, chunkUrl(packageName, 'client.terminal.js', row.rev))).status).toBe(404)
  738. writeFileSync(chunkPath, 'module.exports = { marker: "terminal loaded" }\n')
  739. const url = chunkUrl(packageName, 'client.terminal.js', row.rev)
  740. const head = await routeRequest(route, url, 'HEAD')
  741. expect(head.status).toBe(200)
  742. expect(head.body).toHaveLength(0)
  743. const chunk = await routeRequest(route, url)
  744. expect(chunk.status).toBe(200)
  745. expect(chunk.body.toString('utf8')).toContain('terminal loaded')
  746. expect(chunk.body.toString('utf8')).toContain(`sourceMappingURL=${url.replace('.js?', '.js.map?')}`)
  747. expect((await routeRequest(route, url.replace('.js?', '.js.map?'))).status).toBe(200)
  748. expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
  749. })
  750. it('publishes a new chunk revision when a completed build rewrites only the entry timestamp', async () => {
  751. const packageName = '@fixture/chunk-only-rebuild'
  752. const clientPath = writePackage(packageName)
  753. const chunkPath = join(dirname(clientPath), 'client.terminal.js')
  754. mkdirSync(dirname(clientPath), { recursive: true })
  755. writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.terminal.js") }\n')
  756. writeFileSync(chunkPath, 'module.exports = { generation: 1 }\n')
  757. const { service, route } = constructWithRoute([packageName])
  758. const firstRow = service.graph().entries[0]!
  759. const firstUrl = chunkUrl(packageName, 'client.terminal.js', firstRow.rev)
  760. expect((await routeRequest(route, firstUrl)).body.toString('utf8')).toContain('generation: 1')
  761. writeFileSync(chunkPath, 'module.exports = { generation: 2 }\n')
  762. const entryStat = statSync(clientPath)
  763. const completed = new Date(entryStat.mtimeMs + 1_000)
  764. utimesSync(clientPath, entryStat.atime, completed)
  765. const nextRev = service.rebuilt(packageName)!
  766. expect(nextRev).not.toBe(firstRow.rev)
  767. expect((await routeRequest(route, firstUrl)).status).toBe(404)
  768. const nextUrl = chunkUrl(packageName, 'client.terminal.js', nextRev)
  769. expect((await routeRequest(route, nextUrl)).body.toString('utf8')).toContain('generation: 2')
  770. })
  771. it('applies sourceRoot before relocating absolute-looking section sources', async () => {
  772. const packageName = '@fixture/source-root'
  773. const clientPath = writePackage(packageName)
  774. mkdirSync(dirname(clientPath), { recursive: true })
  775. writeFileSync(clientPath, 'module.exports = {}\n')
  776. writeFileSync(`${clientPath}.map`, JSON.stringify({
  777. version: 3,
  778. names: [],
  779. mappings: 'AAAA',
  780. sourceRoot: '../root',
  781. sources: ['/absolute.ts'],
  782. }))
  783. const { service, route } = constructWithRoute([packageName])
  784. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  785. const map = JSON.parse(response.body.toString('utf8')) as {
  786. sections: { map: { sourceRoot?: string; sources: string[] } }[]
  787. }
  788. expect(map.sections[0]?.map).toMatchObject({
  789. sources: ['/plugins/@fixture/root/absolute.ts'],
  790. })
  791. expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot')
  792. })
  793. it('maps a non-zero second batch section through a standard source-map consumer', async () => {
  794. const firstName = '@fixture/offset-first'
  795. const secondName = '@fixture/offset-second'
  796. const firstPath = writePackage(firstName)
  797. const secondPath = writePackage(secondName)
  798. for (const [path, source] of [
  799. [firstPath, '../../../packages/demo/first.ts'],
  800. [secondPath, '../../../packages/demo/second.ts'],
  801. ] as const) {
  802. mkdirSync(dirname(path), { recursive: true })
  803. writeFileSync(path, 'window.first = true\nwindow.second = true\n')
  804. writeFileSync(`${path}.map`, JSON.stringify({
  805. version: 3,
  806. names: [],
  807. mappings: 'AAAA',
  808. sources: [source],
  809. sourcesContent: ['export {}\n'],
  810. }))
  811. }
  812. const { service, route } = constructWithRoute([firstName, secondName])
  813. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  814. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  815. const sections = (payload as unknown as {
  816. sections: { offset: { line: number; column: number } }[]
  817. }).sections
  818. expect(sections.map(section => section.offset)).toEqual([
  819. { line: 0, column: 0 },
  820. { line: 3, column: 0 },
  821. ])
  822. const consumer = new SourceMap(payload)
  823. expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
  824. expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
  825. })
  826. it('combines a generated-file fallback with a later authored map', async () => {
  827. const unmappedName = '@fixture/unmapped-first'
  828. const mappedName = '@fixture/mapped-second'
  829. const unmappedPath = writePackage(unmappedName)
  830. const mappedPath = writePackage(mappedName)
  831. mkdirSync(dirname(unmappedPath), { recursive: true })
  832. mkdirSync(dirname(mappedPath), { recursive: true })
  833. writeFileSync(unmappedPath, 'window.unmapped = true\n')
  834. writeFileSync(mappedPath, 'window.mapped = true\n')
  835. writeFileSync(`${mappedPath}.map`, JSON.stringify({
  836. version: 3,
  837. names: [],
  838. mappings: 'AAAA',
  839. sources: ['../../../packages/demo/mapped.ts'],
  840. sourcesContent: ['export {}\n'],
  841. }))
  842. const { service, route } = constructWithRoute([unmappedName, mappedName])
  843. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  844. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  845. const consumer = new SourceMap(payload)
  846. expect(consumer.findEntry(0, 0)).toMatchObject({
  847. originalSource: `/plugins/${unmappedName}/client.js`,
  848. })
  849. expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
  850. })
  851. })
  852. function emitLoaderEntryChange(context: Context, name: string): void {
  853. context.emit('internal/plugin', {
  854. entry: { options: { name } },
  855. } as unknown as Fiber)
  856. }
  857. describe('shared module declarations', () => {
  858. it('accepts external requests and carries them onto the graph row', () => {
  859. const packageName = '@fixture/shared-declared'
  860. writeBuiltPackage(packageName, { external: ['react'] })
  861. expect(construct([packageName]).graph().entries).toEqual([{
  862. id: packageName,
  863. url: expect.stringContaining(`/plugins/??${packageName}/client.js&rev=`) as unknown as string,
  864. rev: expect.any(String) as unknown as string,
  865. external: ['react'],
  866. }])
  867. })
  868. it('omits external when the package declares no requests', () => {
  869. const packageName = '@fixture/shared-absent'
  870. writeBuiltPackage(packageName, {})
  871. const [row] = construct([packageName]).graph().entries
  872. expect(row).not.toHaveProperty('external')
  873. })
  874. it('rejects a non-array external', () => {
  875. const packageName = '@fixture/external-not-array'
  876. writeBuiltPackage(packageName, { external: 'react' })
  877. expect(() => construct([packageName]))
  878. .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
  879. })
  880. })
  881. describe('module graph order', () => {
  882. const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
  883. ({ id, url: comboUrl([id], '0'), rev: '0', ...fields })
  884. const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
  885. it('places every requested package row before its consumers along a chain', () => {
  886. expect(ids(orderByModuleGraph([
  887. entry('ui', { external: ['slots'] }),
  888. entry('slots', { external: ['render'] }),
  889. entry('render'),
  890. ]))).toEqual(['render', 'slots', 'ui'])
  891. })
  892. it('places a shared package row before both arms of a diamond', () => {
  893. expect(ids(orderByModuleGraph([
  894. entry('app', { external: ['left', 'right'] }),
  895. entry('left', { external: ['vendor'] }),
  896. entry('right', { external: ['vendor'] }),
  897. entry('vendor'),
  898. ]))).toEqual(['vendor', 'left', 'right', 'app'])
  899. })
  900. it('resolves a /client request onto the requested package row', () => {
  901. expect(ids(orderByModuleGraph([
  902. entry('ui', { external: ['runtime/client'] }),
  903. entry('runtime'),
  904. ]))).toEqual(['runtime', 'ui'])
  905. })
  906. it('leaves a request no row answers to the static assembly channel', () => {
  907. expect(ids(orderByModuleGraph([
  908. entry('consumer', { external: ['@deepseek-ai/cordis'] }),
  909. entry('other'),
  910. ]))).toEqual(['consumer', 'other'])
  911. })
  912. it('rejects a cycle and names the packages on it', () => {
  913. expect(() => orderByModuleGraph([
  914. entry('a', { external: ['b'] }),
  915. entry('b', { external: ['a'] }),
  916. ])).toThrow('client-modules: module graph cycle a -> b -> a')
  917. })
  918. it('rejects a row requesting its own package name', () => {
  919. expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
  920. .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
  921. })
  922. it('composes the served graph in module-graph order', () => {
  923. const consumerName = '@fixture/order-consumer'
  924. const dependencyName = '@fixture/order-dependency'
  925. writeBuiltPackage(consumerName, { external: [dependencyName] })
  926. writeBuiltPackage(dependencyName, {})
  927. expect(ids(construct([consumerName, dependencyName]).graph().entries))
  928. .toEqual([dependencyName, consumerName])
  929. })
  930. it('fails activation loud when scanned packages form a module cycle', () => {
  931. writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
  932. writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
  933. expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
  934. .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
  935. })
  936. })