assembly-roster.client.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /** ClientRoster: construction, duplicate refusal, order-preserving pick/closure/without with loud unknown names; graphFromRoster. */
  2. import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client'
  3. import { describe, expect, it } from 'vitest'
  4. import { ClientRoster, graphFromRoster, type ClientRosterRow } from '../src/assembly/roster.ts'
  5. import { MODULES_PACKAGE } from '../src/assembly/modules.ts'
  6. const ROWS: readonly ClientRosterRow[] = [
  7. { name: MODULES_PACKAGE, inject: [], immediately: true },
  8. { name: '@x/a', inject: [], immediately: false },
  9. { name: '@x/b', inject: ['@x/a'], immediately: true },
  10. ]
  11. describe('graphFromRoster', () => {
  12. it('synthesizes one application batch over every row with placeholder URLs', () => {
  13. expect(graphFromRoster(ROWS)).toEqual({
  14. rev: 'local',
  15. entries: [
  16. { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true },
  17. { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' },
  18. { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true },
  19. ],
  20. batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }],
  21. })
  22. })
  23. it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => {
  24. const manifest = parseBootManifest(graphFromRoster(ROWS))
  25. expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately })))
  26. expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js'])
  27. const row = ROWS[1]!
  28. expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"')
  29. expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array')
  30. })
  31. it('imports seeded rows without reaching the bundle transport', async () => {
  32. const loaded: string[] = []
  33. const pendingQueue: ClientBundleRegistration[] = []
  34. const target: ClientModuleLoaderTarget = {
  35. mode: 'queue',
  36. pendingQueue,
  37. load: (registration) => { pendingQueue.push(registration) },
  38. create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: {} }, options),
  39. }
  40. const a = { apply() {} }
  41. const b = { apply() {} }
  42. const modules = target.create({
  43. boot: graphFromRoster(ROWS),
  44. staticModules: { '@x/a': a, '@x/b': b },
  45. loadBundle: async (url) => { loaded.push(url) },
  46. })
  47. await expect(modules.import('@x/a')).resolves.toBe(a)
  48. await expect(modules.import('@x/b')).resolves.toBe(b)
  49. expect(modules.manifest).toEqual(parseBootManifest(graphFromRoster(ROWS)))
  50. expect(loaded).toEqual([])
  51. })
  52. })
  53. const A = { name: 'a', inject: [], immediately: true }
  54. const B = { name: 'b', inject: ['a'], immediately: false }
  55. const C = { name: 'c', inject: ['b'], immediately: false }
  56. describe('ClientRoster', () => {
  57. it('freezes a copy of the rows in composition order', () => {
  58. const rows = [A, B, C]
  59. const roster = ClientRoster.of(rows)
  60. expect(roster.rows).toEqual([A, B, C])
  61. expect(roster.rows).not.toBe(rows)
  62. expect(Object.isFrozen(roster.rows)).toBe(true)
  63. rows.push(A)
  64. expect(roster.rows).toHaveLength(3)
  65. })
  66. it('refuses duplicate names, listing each duplicate once', () => {
  67. expect(() => ClientRoster.of([A, B, A, C, B, A])).toThrow('duplicate roster rows: a, b')
  68. })
  69. it('pick keeps roster order regardless of the requested order', () => {
  70. const roster = ClientRoster.of([A, B, C])
  71. expect(roster.pick(['c', 'a']).rows.map(row => row.name)).toEqual(['a', 'c'])
  72. expect(roster.pick([]).rows).toEqual([])
  73. expect(Object.isFrozen(roster.pick(['b']).rows)).toBe(true)
  74. })
  75. it('without drops the named rows', () => {
  76. const roster = ClientRoster.of([A, B, C])
  77. expect(roster.without(['b']).rows.map(row => row.name)).toEqual(['a', 'c'])
  78. expect(roster.without(['a', 'b', 'c']).rows).toEqual([])
  79. })
  80. it('closure keeps the named rows and everything they inject, transitively, in roster order', () => {
  81. const D = { name: 'd', inject: [], immediately: false }
  82. const roster = ClientRoster.of([A, D, B, C])
  83. expect(roster.closure(['c']).rows.map(row => row.name)).toEqual(['a', 'b', 'c'])
  84. expect(roster.closure(['b', 'd']).rows.map(row => row.name)).toEqual(['a', 'd', 'b'])
  85. expect(roster.closure(['a']).rows.map(row => row.name)).toEqual(['a'])
  86. expect(roster.closure([]).rows).toEqual([])
  87. expect(Object.isFrozen(roster.closure(['c']).rows)).toBe(true)
  88. })
  89. it('pick, closure, and without throw on unknown names with the roster listed; closure also refuses an inject outside the roster', () => {
  90. const roster = ClientRoster.of([A, B])
  91. expect(() => roster.pick(['a', 'zz'])).toThrow('pick() names outside the roster: zz; roster: a, b')
  92. expect(() => roster.closure(['zz'])).toThrow('closure() names outside the roster: zz; roster: a, b')
  93. expect(() => roster.without(['x', 'y'])).toThrow('without() names outside the roster: x, y; roster: a, b')
  94. expect(() => ClientRoster.of([B]).closure(['b'])).toThrow('b injects a, which is outside the roster')
  95. })
  96. it('closure treats the platform seed modules as satisfied without a row', () => {
  97. const seeded = { name: 'seeded', inject: ['@deepseek-ai/dsh-client-ui-primitives', 'react', 'a'], immediately: false }
  98. expect(ClientRoster.of([A, seeded]).closure(['seeded']).rows.map(row => row.name)).toEqual(['a', 'seeded'])
  99. })
  100. })