verify-client-packages.spec.ts 19 KB

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