assembly-bundle-roster.client.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */
  2. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts'
  6. import { afterAll, describe, expect, it } from 'vitest'
  7. import { MODULES_PACKAGE } from '../src/assembly/modules.ts'
  8. import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts'
  9. describe('webApp (the real web profile)', () => {
  10. it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => {
  11. expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
  12. const names = webApp.rows.map(row => row.name)
  13. expect(new Set(names).size).toBe(names.length)
  14. const known = new Set([...names, ...Object.keys(getStaticModules())])
  15. const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`))
  16. expect(dangling).toEqual([])
  17. expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows)
  18. })
  19. it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => {
  20. const immediate = new Set(webApp.rows.filter(row => row.immediately).map(row => row.name))
  21. expect(immediate.has(MODULES_PACKAGE)).toBe(true)
  22. expect(immediate.has('@deepseek-ai/dsh-client-connection')).toBe(true)
  23. expect(webApp.rows.find(row => row.name === '@deepseek-ai/dsh-api-gateway')?.inject)
  24. .toEqual(['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'])
  25. const names = webApp.rows.map(row => row.name)
  26. expect(names).toContain('@deepseek-ai/dsh-client-ui-settings-general')
  27. expect(names).not.toContain('@deepseek-ai/dsh-llm') // Host only
  28. expect(names).not.toContain('@deepseek-ai/dsh-client-ui-schedule') // inserted disabled
  29. expect(names).not.toContain('@deepseek-ai/dsh-web-app') // Host runtime glue, its `/startup` row is a subpath
  30. })
  31. })
  32. /** A scratch installation: an anchor package, bundles under its node_modules, plugin packages beside them. */
  33. class Scratch {
  34. readonly root = mkdtempSync(join(tmpdir(), 'bundle-roster-'))
  35. readonly anchor = join(this.root, 'app', 'package.json')
  36. constructor() {
  37. mkdirSync(join(this.root, 'app'), { recursive: true })
  38. writeFileSync(this.anchor, JSON.stringify({ name: 'app' }))
  39. }
  40. pkg(name: string, manifest: Record<string, unknown>, files: Record<string, string> = {}): void {
  41. const dir = join(this.root, 'app', 'node_modules', name)
  42. mkdirSync(dir, { recursive: true })
  43. writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...manifest }))
  44. for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content)
  45. }
  46. bundle(name: string, patch: string): void {
  47. this.pkg(name, { dsh: { bundle: { patch: './cordis.patch.yml' } } }, { 'cordis.patch.yml': patch })
  48. }
  49. web(name: string, client: Record<string, unknown> = {}): void {
  50. this.pkg(name, { dsh: { client: { platform: 'web', ...client } } })
  51. }
  52. roster(bundles: readonly string[]): readonly string[] {
  53. return bundleRoster(bundles, this.anchor).rows.map(row => row.name)
  54. }
  55. }
  56. describe('bundleRoster on a scratch installation', () => {
  57. const scratch = new Scratch()
  58. afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) })
  59. it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => {
  60. scratch.web('@t/a', { inject: ['@t/b'], immediately: true })
  61. scratch.web('@t/b')
  62. scratch.web('@t/c')
  63. scratch.web('plain')
  64. scratch.pkg('@t/host', { dsh: {} })
  65. scratch.pkg('@t/node', { dsh: { client: { platform: 'node' } } })
  66. scratch.bundle('@t/base', `
  67. - insert:
  68. - id: a
  69. name: '@t/a'
  70. config:
  71. root: !!js process.cwd()
  72. - id: host
  73. name: '@t/host'
  74. disabled: !!js process.platform === 'win32'
  75. - id: node
  76. name: '@t/node'
  77. - id: c
  78. name: '@t/c'
  79. - id: sub
  80. name: '@t/a/extra'
  81. - id: builtin
  82. name: 'cordis:group'
  83. `)
  84. scratch.bundle('@t/web', `
  85. - id: c
  86. disabled: true
  87. - insert:
  88. - id: b
  89. name: '@t/b'
  90. - id: b-again
  91. name: '@t/b'
  92. - id: plain
  93. name: plain
  94. - id: off
  95. name: '@t/off'
  96. disabled: true
  97. `)
  98. const roster = bundleRoster(['@t/base', '@t/web'], scratch.anchor)
  99. expect(roster.rows).toEqual([
  100. { name: '@t/a', inject: ['@t/b'], immediately: true },
  101. { name: '@t/b', inject: [], immediately: false },
  102. { name: 'plain', inject: [], immediately: false },
  103. ])
  104. expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c'])
  105. })
  106. it('descends into Loader groups and lets a disabled group disable every row beneath it', () => {
  107. scratch.web('@t/grouped')
  108. scratch.web('@t/grouped-off')
  109. scratch.web('@t/nested')
  110. scratch.bundle('@t/groups', `
  111. - insert:
  112. - id: on
  113. name: cordis:group
  114. group: true
  115. config:
  116. - id: grouped
  117. name: '@t/grouped'
  118. - id: inner
  119. name: cordis:group
  120. group: true
  121. config:
  122. - id: nested
  123. name: '@t/nested'
  124. - id: off
  125. name: cordis:group
  126. group: true
  127. disabled: true
  128. config:
  129. - id: grouped-off
  130. name: '@t/grouped-off'
  131. `)
  132. expect(scratch.roster(['@t/groups'])).toEqual(['@t/grouped', '@t/nested'])
  133. })
  134. it('refuses a browser row whose disabled value is a !!js expression', () => {
  135. scratch.web('@t/maybe')
  136. scratch.bundle('@t/maybe-bundle', `
  137. - insert:
  138. - id: maybe
  139. name: '@t/maybe'
  140. disabled: !!js process.platform === 'win32'
  141. `)
  142. expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate')
  143. })
  144. it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => {
  145. expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from')
  146. scratch.pkg('@t/no-patch', { dsh: {} })
  147. expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in')
  148. scratch.bundle('@t/not-a-list', 'insert: []\n')
  149. expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches')
  150. })
  151. it('fails loud on a patch that does not apply as written', () => {
  152. scratch.bundle('@t/dangling', `
  153. - id: nowhere
  154. disabled: true
  155. `)
  156. expect(() => scratch.roster(['@t/dangling'])).toThrow('bundle patch patch: entry "nowhere" not found')
  157. })
  158. it('fails loud on an enabled row whose package does not resolve or names another package', () => {
  159. scratch.bundle('@t/unresolved', `
  160. - insert:
  161. - id: ghost
  162. name: '@t/ghost'
  163. `)
  164. expect(() => scratch.roster(['@t/unresolved'])).toThrow('cannot resolve plugin package @t/ghost from @t/unresolved')
  165. scratch.bundle('@t/builtin', `
  166. - insert:
  167. - id: fs
  168. name: fs
  169. `)
  170. // A Node builtin name has no resolution paths at all.
  171. expect(() => scratch.roster(['@t/builtin'])).toThrow('cannot resolve plugin package fs from @t/builtin')
  172. scratch.pkg('@t/alias', { name: '@t/real' })
  173. scratch.bundle('@t/misnamed', `
  174. - insert:
  175. - id: alias
  176. name: '@t/alias'
  177. `)
  178. expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias')
  179. })
  180. })