verify-client-packages.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. /** Tests for client package modes, dependency sections, and module requests. */
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import {
  7. collectClientPackageViolations,
  8. collectSourcePackageUses,
  9. fixClientPackageManifests,
  10. readClientDeclarations,
  11. type ClientDeclaration,
  12. type ClientPackage,
  13. type ClientPackageFacts,
  14. } from './verify-client-packages.ts'
  15. const CORDIS = '@deepseek-ai/cordis'
  16. const roots: string[] = []
  17. afterEach(() => {
  18. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  19. })
  20. function declaration(
  21. short: string,
  22. fields: Partial<Omit<ClientDeclaration, 'name' | 'manifest'>> = {},
  23. ): ClientDeclaration {
  24. return {
  25. name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
  26. manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
  27. dynamic: true,
  28. external: [],
  29. inject: [],
  30. ...fields,
  31. }
  32. }
  33. function pkg(
  34. short: string,
  35. fields: Partial<Omit<ClientPackage, 'name' | 'manifest'>> = {},
  36. ): ClientPackage {
  37. return {
  38. ...declaration(short),
  39. staticLinked: false,
  40. sourceUses: {},
  41. dependencies: {},
  42. peerDependencies: { [CORDIS]: 'workspace:^' },
  43. devDependencies: { [CORDIS]: 'workspace:^' },
  44. ...fields,
  45. }
  46. }
  47. function facts(
  48. packages: readonly ClientPackage[],
  49. options: Partial<Omit<ClientPackageFacts, 'packages'>> = {},
  50. ): ClientPackageFacts {
  51. return {
  52. packages,
  53. declarations: options.declarations ?? packages,
  54. staticLinkedPackages: options.staticLinkedPackages ?? new Set(
  55. packages.filter(item => item.staticLinked).map(item => item.name),
  56. ),
  57. platformModules: options.platformModules ?? [],
  58. preloadedExternals: options.preloadedExternals ?? [],
  59. malformed: options.malformed ?? [],
  60. }
  61. }
  62. describe('source package uses', () => {
  63. it('counts type imports, module augmentations, dynamic imports, and JSX', () => {
  64. const uses = collectSourcePackageUses('feature.tsx', [
  65. "import type { A } from '@deepseek-ai/dsh-a/subpath'",
  66. "declare module '@deepseek-ai/dsh-client-ui-slots' {}",
  67. "const load = () => import('@deepseek-ai/dsh-b')",
  68. 'export const view = <div />',
  69. "export type { Local } from './local.ts'",
  70. ].join('\n'))
  71. expect([...uses].sort()).toEqual([
  72. '@deepseek-ai/dsh-a',
  73. '@deepseek-ai/dsh-b',
  74. '@deepseek-ai/dsh-client-ui-slots',
  75. 'react',
  76. ])
  77. })
  78. })
  79. describe('package modes', () => {
  80. it('accepts one dynamic package and one statically linked package', () => {
  81. const dynamic = pkg('runtime')
  82. const shell = pkg('ui-slots', { dynamic: false, staticLinked: true })
  83. expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([])
  84. })
  85. it('rejects a package with both modes or neither mode', () => {
  86. const both = pkg('both', { staticLinked: true })
  87. const neither = pkg('neither', { dynamic: false })
  88. const found = collectClientPackageViolations(facts([both, neither]))
  89. expect(found).toHaveLength(2)
  90. expect(found.join('\n')).toContain('must be dynamic or statically linked, not both')
  91. expect(found.join('\n')).toContain('has no supported client package mode')
  92. })
  93. it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => {
  94. const slots = declaration('ui-slots', { dynamic: false })
  95. const runtime = declaration('runtime', { dynamic: false })
  96. const found = collectClientPackageViolations(facts([], {
  97. declarations: [slots, runtime],
  98. platformModules: [slots.name],
  99. preloadedExternals: [runtime.name + '/client'],
  100. }))
  101. expect(found).toHaveLength(2)
  102. expect(found.join('\n')).toContain('does not use the staticLinked preset')
  103. expect(found.join('\n')).toContain('has no dynamic dsh.client row')
  104. })
  105. })
  106. describe('dependency sections', () => {
  107. it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => {
  108. const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
  109. const runtime = pkg('runtime', {
  110. inject: ['@deepseek-ai/dsh-client-feature'],
  111. sourceUses: {
  112. '@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'],
  113. '@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'],
  114. react: ['packages/client/runtime/src/client/view.tsx'],
  115. },
  116. dependencies: { immer: '^10.1.1' },
  117. peerDependencies: {
  118. [CORDIS]: 'workspace:^',
  119. '@deepseek-ai/dsh-agent': 'workspace:^',
  120. '@deepseek-ai/dsh-client-feature': 'workspace:^',
  121. },
  122. devDependencies: {
  123. [CORDIS]: 'workspace:^',
  124. '@deepseek-ai/dsh-agent': 'workspace:^',
  125. '@deepseek-ai/dsh-client-feature': 'workspace:^',
  126. '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
  127. react: '^18.2.0',
  128. },
  129. })
  130. expect(collectClientPackageViolations(facts([slots, runtime], {
  131. platformModules: ['react', slots.name],
  132. }))).toEqual([])
  133. })
  134. it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
  135. const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
  136. const subject = pkg('feature', {
  137. sourceUses: {
  138. '@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
  139. [slots.name]: ['packages/client/feature/src/view.tsx'],
  140. },
  141. dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
  142. peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
  143. devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
  144. })
  145. const found = collectClientPackageViolations(facts([slots, subject]))
  146. expect(found).toHaveLength(2)
  147. expect(found.join('\n')).toContain('peer-installed DSH relationship')
  148. expect(found.join('\n')).toContain('static client input')
  149. })
  150. it('requires every peer to have the same development range', () => {
  151. const subject = pkg('feature', {
  152. peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
  153. })
  154. expect(collectClientPackageViolations(facts([subject]))).toEqual([
  155. 'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
  156. + ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
  157. + ' found no declaration',
  158. ])
  159. })
  160. it('allows npm dependency cycles', () => {
  161. const a = pkg('a', {
  162. peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
  163. devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
  164. })
  165. const b = pkg('b', {
  166. peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
  167. devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
  168. })
  169. expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
  170. })
  171. })
  172. describe('module requests', () => {
  173. it('accepts a dynamic row supplier and its client subpath', () => {
  174. const ui = declaration('ui', { external: ['@deepseek-ai/dsh-client-slots/client'] })
  175. const slots = declaration('slots')
  176. expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([])
  177. })
  178. it('rejects an explicit baseline request', () => {
  179. const ui = declaration('ui', { external: ['react'] })
  180. expect(collectClientPackageViolations(facts([], {
  181. declarations: [ui],
  182. platformModules: ['react'],
  183. }))).toEqual([
  184. ui.manifest + ': dsh.client.external repeats baseline module "react"; remove the explicit declaration',
  185. ])
  186. })
  187. it('rejects duplicates, empty values, self-requests, and missing suppliers', () => {
  188. const ui = declaration('ui', {
  189. external: ['', '@deepseek-ai/dsh-client-ui', '@deepseek-ai/dsh-missing', '@deepseek-ai/dsh-missing'],
  190. inject: ['', '@deepseek-ai/dsh-a', '@deepseek-ai/dsh-a'],
  191. })
  192. const found = collectClientPackageViolations(facts([], { declarations: [ui] }))
  193. expect(found).toHaveLength(6)
  194. expect(found.join('\n')).toContain('dsh.client.external contains an empty value')
  195. expect(found.join('\n')).toContain('dsh.client.inject contains an empty value')
  196. expect(found.join('\n')).toContain('names its own row')
  197. expect(found.join('\n')).toContain('has no supplier')
  198. })
  199. it('rejects synchronous module-request cycles but ignores inject cycles', () => {
  200. const a = declaration('a', {
  201. external: ['@deepseek-ai/dsh-client-b'],
  202. inject: ['@deepseek-ai/dsh-client-b'],
  203. })
  204. const b = declaration('b', {
  205. external: ['@deepseek-ai/dsh-client-a'],
  206. inject: ['@deepseek-ai/dsh-client-a'],
  207. })
  208. const found = collectClientPackageViolations(facts([], { declarations: [a, b] }))
  209. expect(found).toHaveLength(1)
  210. expect(found[0]).toContain('synchronous dsh.client.external cycle')
  211. })
  212. })
  213. describe('manifest declarations', () => {
  214. it('reports malformed arrays without hiding other packages', () => {
  215. const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
  216. roots.push(root)
  217. const files: Record<string, unknown> = {
  218. 'packages/g/a/package.json': {
  219. name: '@f/a', dsh: { client: { external: 'react', inject: ['@f/b', 1] } },
  220. },
  221. 'packages/g/b/package.json': { name: '@f/b', dsh: { client: {} } },
  222. }
  223. for (const [path, value] of Object.entries(files)) {
  224. mkdirSync(dirname(join(root, path)), { recursive: true })
  225. writeFileSync(join(root, path), JSON.stringify(value))
  226. }
  227. const result = readClientDeclarations(root)
  228. expect(result.declarations).toHaveLength(2)
  229. expect(result.malformed).toEqual([
  230. 'packages/g/a/package.json: @f/a dsh.client.external must be a string array',
  231. 'packages/g/a/package.json: @f/a dsh.client.inject must be a string array',
  232. ])
  233. })
  234. it('fixes unambiguous dependency sections and declaration entries', () => {
  235. const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
  236. roots.push(root)
  237. const subject = pkg('feature', {
  238. external: ['', 'react', '@deepseek-ai/dsh-client-feature', '@deepseek-ai/dsh-missing'],
  239. inject: ['', '@deepseek-ai/dsh-agent', '@deepseek-ai/dsh-agent'],
  240. sourceUses: {
  241. '@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
  242. '@deepseek-ai/dsh-client-ui-slots': ['packages/client/feature/src/view.tsx'],
  243. },
  244. dependencies: {
  245. [CORDIS]: 'workspace:^',
  246. '@deepseek-ai/dsh-agent': 'workspace:*',
  247. },
  248. peerDependencies: {
  249. '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
  250. '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
  251. },
  252. devDependencies: {},
  253. })
  254. const slots = declaration('ui-slots', { dynamic: false })
  255. const manifest = {
  256. name: subject.name,
  257. dsh: { client: { external: subject.external, inject: subject.inject, platform: 'web' } },
  258. dependencies: subject.dependencies,
  259. peerDependencies: subject.peerDependencies,
  260. devDependencies: subject.devDependencies,
  261. }
  262. mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
  263. writeFileSync(join(root, subject.manifest), JSON.stringify(manifest))
  264. writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
  265. expect(fixClientPackageManifests(root, facts([subject], {
  266. declarations: [subject, slots],
  267. staticLinkedPackages: new Set([slots.name]),
  268. platformModules: ['react', slots.name],
  269. }))).toEqual([subject.manifest])
  270. const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
  271. dsh: { client: { external: string[]; inject: string[] } }
  272. dependencies?: Record<string, string>
  273. peerDependencies: Record<string, string>
  274. devDependencies: Record<string, string>
  275. }
  276. expect(fixed.dsh.client).toMatchObject({
  277. external: ['@deepseek-ai/dsh-missing'],
  278. inject: ['@deepseek-ai/dsh-agent'],
  279. })
  280. expect(fixed.dependencies).toBeUndefined()
  281. expect(fixed.peerDependencies).toEqual({
  282. '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
  283. [CORDIS]: 'workspace:^',
  284. '@deepseek-ai/dsh-agent': 'workspace:*',
  285. })
  286. expect(fixed.devDependencies).toEqual({
  287. '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
  288. [CORDIS]: 'workspace:^',
  289. '@deepseek-ai/dsh-agent': 'workspace:*',
  290. '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
  291. })
  292. })
  293. })