verify-client-packages.spec.ts 14 KB

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