gen-client-catalog.spec.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. /**
  2. * The client slot catalog's judgement, proven on hand-built inputs: the
  3. * contract checks that must reject an unteachable slot, and the projection
  4. * facts a registrant depends on (who occupies a seat, what replacing it costs,
  5. * which owner has to be mounted). Run against the real workspace, the
  6. * generator's own `--check` covers freshness; these cases pin the rules that
  7. * make a stale or undocumented contract fail loudly instead of shipping.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import { collectSlotEntries, oversizedSlotReports, resolveSlotEntries, validateSlotContracts } from './gen-client-catalog.ts'
  11. import type { SlotDeclaration, SlotRegistration, TypeDeclaration } from './slot-walk.ts'
  12. /** A declaration with every field the catalog needs, overridable per case. */
  13. function declaration(over: Partial<SlotDeclaration> = {}): SlotDeclaration {
  14. return {
  15. key: 'demo.seat',
  16. kind: 'single',
  17. scope: 'root',
  18. jsDoc: '/** A seat. Registering here replaces the shipped entry. */',
  19. package: '@deepseek-ai/dsh-client-demo',
  20. source: 'packages/client/demo/src/client/contract/slots.ts:1',
  21. ...over,
  22. }
  23. }
  24. /** A registration into `demo.seat`, overridable per case. */
  25. function registration(over: Partial<SlotRegistration> = {}): SlotRegistration {
  26. return {
  27. key: 'demo.seat',
  28. package: '@deepseek-ai/dsh-client-demo',
  29. component: 'DemoSeat',
  30. children: [],
  31. source: 'packages/client/demo/src/client/index.ts:10',
  32. ...over,
  33. }
  34. }
  35. /** An exported owner-props declaration the catalog can resolve. */
  36. const OWNER_TYPES = new Map<string, TypeDeclaration>([
  37. ['DemoOwnerProps', {
  38. name: 'DemoOwnerProps',
  39. text: '/** Owner share. */\nexport interface DemoOwnerProps {\n /** Column width. */\n width: number\n}',
  40. source: 'packages/client/demo/src/client/contract/slots.ts:20',
  41. }],
  42. ])
  43. describe('client slot contract validation', () => {
  44. it('accepts a documented slot whose owner props resolve', () => {
  45. expect(validateSlotContracts(
  46. [declaration({ ownerType: 'DemoOwnerProps' })],
  47. [registration()],
  48. OWNER_TYPES,
  49. )).toEqual([])
  50. })
  51. it('rejects a slot with no registrant-facing prose, naming the writing template', () => {
  52. const problems = validateSlotContracts([declaration({ jsDoc: '' })], [], new Map())
  53. expect(problems).toHaveLength(1)
  54. expect(problems[0]).toContain('has no JSDoc prose')
  55. expect(problems[0]).toContain('ui-settings')
  56. })
  57. it.each([
  58. ['kind', { kind: 'whatever' }],
  59. ['scope', { scope: 'whatever' }],
  60. ])('rejects a slot whose %s is not one of the contract literals', (field, over) => {
  61. const problems = validateSlotContracts([declaration(over)], [], new Map())
  62. expect(problems).toHaveLength(1)
  63. expect(problems[0]).toContain(`no literal '${field}'`)
  64. })
  65. it('rejects owner props no exported declaration provides', () => {
  66. const problems = validateSlotContracts([declaration({ ownerType: 'MissingProps' })], [], new Map())
  67. expect(problems).toHaveLength(1)
  68. expect(problems[0]).toContain('MissingProps')
  69. })
  70. it('rejects the same key declared twice, because a merge would hide one contract', () => {
  71. const problems = validateSlotContracts(
  72. [declaration(), declaration({ source: 'packages/client/other/src/client/slots.ts:3' })],
  73. [],
  74. new Map(),
  75. )
  76. expect(problems).toHaveLength(1)
  77. expect(problems[0]).toContain('is also declared at')
  78. })
  79. it('rejects a registration into an undeclared slot as a scan blind spot', () => {
  80. const problems = validateSlotContracts([declaration()], [registration({ key: 'ghost.seat' })], new Map())
  81. expect(problems).toHaveLength(1)
  82. expect(problems[0]).toContain('blind spot')
  83. })
  84. it('rejects a children declaration for a slot no merge types', () => {
  85. const problems = validateSlotContracts([declaration()], [registration({ children: ['ghost.child'] })], new Map())
  86. expect(problems).toHaveLength(1)
  87. expect(problems[0]).toContain("child slot 'ghost.child'")
  88. })
  89. })
  90. describe('client slot projection', () => {
  91. const kits = new Map<string, readonly string[]>([['root', ['useSessions: Hook']]])
  92. it('warns that a single seat with a shipped occupant is replaced, not shared', () => {
  93. const [entry] = resolveSlotEntries([declaration()], [registration()], OWNER_TYPES, kits)
  94. expect(entry?.replaceRisk).toBe('shadows-shipped-ui')
  95. expect(entry?.occupants).toEqual(['client-demo DemoSeat'])
  96. })
  97. it('treats a list seat as additive even when shipped entries exist', () => {
  98. const [entry] = resolveSlotEntries(
  99. [declaration({ kind: 'list' })],
  100. [registration({ id: 'shipped' })],
  101. OWNER_TYPES,
  102. kits,
  103. )
  104. expect(entry?.replaceRisk).toBe('none')
  105. expect(entry?.occupants).toEqual(["client-demo DemoSeat id 'shipped'"])
  106. expect(entry?.registerOptions.map(option => option.name)).toEqual(['id', 'order', 'label'])
  107. })
  108. it('names the entry whose mount makes a child seat exist', () => {
  109. const parent = registration({ key: 'demo.parent', children: ['demo.seat'] })
  110. const entries = resolveSlotEntries(
  111. [declaration(), declaration({ key: 'demo.parent' })],
  112. [parent],
  113. OWNER_TYPES,
  114. kits,
  115. )
  116. expect(entries.find(entry => entry.key === 'demo.seat')?.declaredBy)
  117. .toContain("an entry in 'demo.parent' (client-demo)")
  118. expect(entries.find(entry => entry.key === 'demo.parent')?.declaredBy)
  119. .toContain('built in')
  120. })
  121. it('reports an open keyed domain and the keys already taken', () => {
  122. const [entry] = resolveSlotEntries(
  123. [declaration({ kind: 'keyed' })],
  124. [registration({ entryKey: 'bash' }), registration({ entryKey: 'read' })],
  125. OWNER_TYPES,
  126. kits,
  127. )
  128. expect(entry?.keyDomain).toContain('open: any string')
  129. expect(entry?.keyDomain).toContain('already taken: bash, read')
  130. })
  131. it('carries owner-props documentation into the entry, not just the type name', () => {
  132. const [entry] = resolveSlotEntries([declaration({ ownerType: 'DemoOwnerProps' })], [], OWNER_TYPES, kits)
  133. expect(entry?.ownerProps.join('\n')).toContain('Column width.')
  134. })
  135. it('expands owner props one level and only names the shapes they reference', () => {
  136. // Transitive expansion once dragged the whole session model into four
  137. // seats; a registrant needs the fields, not the graph behind them.
  138. const types = new Map(OWNER_TYPES)
  139. types.set('Zone', {
  140. name: 'Zone',
  141. text: 'export interface Zone {\n session: BigSnapshot\n}',
  142. source: 'packages/client/demo/src/client/contract/slots.ts:30',
  143. })
  144. types.set('BigSnapshot', {
  145. name: 'BigSnapshot',
  146. text: 'export interface BigSnapshot {\n turns: number\n}',
  147. source: 'packages/client/demo/src/client/snapshot.ts:1',
  148. })
  149. const [entry] = resolveSlotEntries([declaration({ ownerType: 'Zone' })], [], types, kits)
  150. expect(entry?.ownerProps.join('\n')).toContain('export interface Zone')
  151. expect(entry?.ownerProps.join('\n')).not.toContain('export interface BigSnapshot')
  152. expect(entry?.ownerPropsReferences).toEqual(['BigSnapshot'])
  153. })
  154. it('offers a runnable registration whose options match the cardinality', () => {
  155. const [entry] = resolveSlotEntries([declaration({ kind: 'list' })], [], OWNER_TYPES, kits)
  156. expect(entry?.example).toContain("ctx.slots.inject('demo.seat'")
  157. expect(entry?.example).toContain("id: 'my-entry'")
  158. })
  159. })
  160. describe('the per-slot report budget', () => {
  161. it('rejects a slot whose report a model could not finish reading', () => {
  162. // Truncation already bounds one declaration, so the remaining runaway is
  163. // prose: a contract that grew into a manual costs exactly what narrowing to
  164. // one slot was supposed to save.
  165. const manual = ['/**', ...Array.from({ length: 150 }, (_, i) => ` * Paragraph ${String(i)} about this seat.`), ' */']
  166. const entries = resolveSlotEntries([declaration({ jsDoc: manual.join('\n') })], [], OWNER_TYPES, new Map())
  167. const problems = oversizedSlotReports(entries)
  168. expect(problems).toHaveLength(1)
  169. expect(problems[0]).toContain("slot 'demo.seat'")
  170. expect(problems[0]).toContain('tighten')
  171. })
  172. it('passes a slot whose report stays within the budget', () => {
  173. const entries = resolveSlotEntries([declaration({ ownerType: 'DemoOwnerProps' })], [], OWNER_TYPES, new Map())
  174. expect(oversizedSlotReports(entries)).toEqual([])
  175. })
  176. })
  177. describe('the real workspace surface', () => {
  178. it('collects every declared slot with a teachable contract', { timeout: 30_000 }, () => {
  179. const entries = collectSlotEntries(process.cwd())
  180. expect(entries.length).toBeGreaterThan(30)
  181. for (const entry of entries) {
  182. expect(entry.summary, `${entry.key} has no summary`).not.toBe('')
  183. expect(['single', 'list', 'keyed', 'chain']).toContain(entry.kind)
  184. expect(['root', 'session', 'session-maybe']).toContain(entry.scope)
  185. }
  186. // The frame root is the canonical trap: occupied by the shipped app frame,
  187. // so a dynamic package registering there replaces the whole UI.
  188. const root = entries.find(entry => entry.key === 'root')
  189. expect(root?.replaceRisk).toBe('shadows-shipped-ui')
  190. expect(root?.occupants.join(' ')).toContain('AppFrame')
  191. })
  192. })