verify-client-packages.spec.ts 15 KB

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