verify-client-packages.spec.ts 16 KB

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