client-bundle-purity.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. /**
  2. * Pins shared client-bundle preset rules: module-edge purity, source-map
  3. * chaining, and physical watch dependencies hidden behind virtual CSS Modules.
  4. */
  5. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { fileURLToPath } from 'node:url'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
  11. type ResolveId = (source: string) => null | { id: string; external: boolean }
  12. interface CssModulePlugin {
  13. name: string
  14. resolveId?: (source: string, importer: string | undefined) => null | string
  15. load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
  16. }
  17. interface SourceMapPlugin {
  18. name: string
  19. load?: (id: string) => Promise<unknown>
  20. }
  21. /** A representative dynamic bundle using the shared client baseline. */
  22. const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
  23. function clientConfigs(id = REQUESTING_PACKAGE) {
  24. return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
  25. { env: { DSH_BUILD_FACE: 'client' } },
  26. ).filter(config => config.platform === 'browser')
  27. }
  28. describe('client bundle build faces', () => {
  29. it('watches source in development and consumes emitted JavaScript in the Client build', () => {
  30. const bundle = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js'])
  31. const development = bundle({ env: {} }).find(config => config.platform === 'browser')
  32. const artifact = bundle({ env: { DSH_BUILD_FACE: 'client' } })
  33. .find(config => config.platform === 'browser')
  34. expect(development?.entry).toEqual({ client: 'src/client/index.ts' })
  35. expect(artifact?.entry).toEqual({ client: 'lib/types/client/index.js' })
  36. })
  37. })
  38. function clientSourceMapPath(packagePath: string): string {
  39. return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
  40. }
  41. function purityResolveId(id = REQUESTING_PACKAGE): ResolveId {
  42. // libEntry is spelled at every call site (no default) so the
  43. // package-invariants text check can see the invariant entry per package.
  44. const configs = clientConfigs(id)
  45. const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
  46. const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
  47. if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
  48. return gate.resolveId as ResolveId
  49. }
  50. function cssModulePlugin(): CssModulePlugin {
  51. const configs = clientConfigs()
  52. const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins
  53. const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
  54. if (plugin?.resolveId === undefined || plugin.load === undefined) {
  55. throw new Error('CSS Modules plugin missing from client config')
  56. }
  57. return plugin
  58. }
  59. function sourceMapPlugin(): SourceMapPlugin {
  60. const configs = clientConfigs()
  61. const plugins = (configs[0] as { plugins: SourceMapPlugin[] }).plugins
  62. const plugin = plugins.find(candidate => candidate.name === 'dsh-tsc-sourcemap')
  63. if (plugin?.load === undefined) throw new Error('tsc sourcemap plugin missing from client config')
  64. return plugin
  65. }
  66. describe('client bundle purity gate', () => {
  67. const resolveId = purityResolveId()
  68. it('leaves default externals and non-scoped specifiers alone', () => {
  69. expect(resolveId('@deepseek-ai/dsh-client-store')).toBeNull()
  70. expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
  71. expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
  72. expect(resolveId('react')).toBeNull()
  73. expect(resolveId('zod')).toBeNull()
  74. })
  75. it('rejects the retired web-react platform package', () => {
  76. expect(() => resolveId('@deepseek-ai/dsh-client-web-react')).toThrow(/purity/)
  77. expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
  78. })
  79. it('lets inline-safe libraries inline', () => {
  80. expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
  81. expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
  82. expect(resolveId('@deepseek-ai/dsh-deque')).toBeNull()
  83. expect(resolveId('@deepseek-ai/dsh-util-values')).toBeNull()
  84. expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
  85. expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
  86. expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
  87. })
  88. it('admits only the pure spill notice entry, not its Host policy', () => {
  89. expect(resolveId('@deepseek-ai/dsh-spill-policy/notice')).toBeNull()
  90. expect(resolveId('@deepseek-ai/dsh-output-retention')).toBeNull()
  91. expect(() => resolveId('@deepseek-ai/dsh-spill-policy')).toThrow(/purity/)
  92. expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
  93. })
  94. it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
  95. expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
  96. expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
  97. expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/)
  98. expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/)
  99. })
  100. it('throws on any other @deepseek-ai leak', () => {
  101. expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
  102. expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
  103. })
  104. it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => {
  105. expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
  106. expect(() => resolveId('@deepseek-ai/dsh-client-ui-session')).toThrow(/purity/)
  107. expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
  108. })
  109. it('admits package-specific requests only for the declaring bundle', () => {
  110. const requesting = purityResolveId('@deepseek-ai/dsh-api-session-controller')
  111. expect(requesting('@deepseek-ai/dsh-api-gateway/client')).toBeNull()
  112. expect(() => resolveId('@deepseek-ai/dsh-api-gateway/client')).toThrow(/purity/)
  113. })
  114. it('externalizes the baseline independently of each package manifest', () => {
  115. const requesting = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
  116. const plain = clientConfigs('@deepseek-ai/dsh-client-connection')[0]?.deps as {
  117. neverBundle: (specifier: string) => boolean
  118. }
  119. expect(requesting.neverBundle('react')).toBe(true)
  120. expect(requesting.neverBundle('zod')).toBe(false)
  121. expect(plain.neverBundle('react')).toBe(true)
  122. expect(plain.neverBundle('@deepseek-ai/dsh-client-store')).toBe(true)
  123. })
  124. })
  125. describe('client bundle module requests', () => {
  126. it('requests what the declaration lists', () => {
  127. const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
  128. external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-slots'],
  129. })
  130. expect([...requests].sort()).toEqual([
  131. '@deepseek-ai/dsh-client-ui-slots', 'react', 'react/jsx-runtime',
  132. ])
  133. })
  134. it('requests nothing when the declaration is absent', () => {
  135. expect(requestedExternals('@deepseek-ai/dsh-client-fixture', {}).size).toBe(0)
  136. })
  137. it('rejects a malformed declaration instead of reading past it', () => {
  138. expect(() => requestedExternals('@deepseek-ai/dsh-client-fixture', { external: 'react' }))
  139. .toThrow(/dsh\.client\.external must be a string array/)
  140. })
  141. })
  142. describe('client bundle debug artifacts', () => {
  143. it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
  144. const configs = clientConfigs()
  145. expect(configs[0]?.sourcemap).toBe(true)
  146. expect(configs[0]?.outputOptions).toMatchObject({ sourcemapExcludeSources: false })
  147. })
  148. it('chains emitted tsc maps when the production Client build consumes lib/types', async () => {
  149. const root = mkdtempSync(join(tmpdir(), 'dsh-client-sourcemap-'))
  150. try {
  151. const entry = join(root, 'lib', 'types', 'client', 'index.js')
  152. const source = join(root, 'src', 'client', 'index.ts')
  153. const map = { version: 3, names: [], mappings: 'AAAA', sources: ['../../../src/client/index.ts'] }
  154. mkdirSync(join(root, 'lib', 'types', 'client'), { recursive: true })
  155. mkdirSync(join(root, 'src', 'client'), { recursive: true })
  156. writeFileSync(entry, 'export const marker = true\n//# sourceMappingURL=index.js.map\n')
  157. writeFileSync(`${entry}.map`, JSON.stringify(map))
  158. writeFileSync(source, 'export const marker: true = true\n')
  159. await expect(sourceMapPlugin().load!(entry)).resolves.toEqual({
  160. code: 'export const marker = true',
  161. map: { ...map, sourcesContent: ['export const marker: true = true\n'] },
  162. })
  163. } finally {
  164. rmSync(root, { recursive: true, force: true })
  165. }
  166. })
  167. it('maps first-party sources to their repository package paths', () => {
  168. const configs = clientConfigs('@deepseek-ai/dsh-client-ui-goal')
  169. const outputOptions = configs[0]?.outputOptions
  170. if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
  171. const transform = outputOptions.sourcemapPathTransform
  172. if (transform === undefined) throw new Error('client sourcemap path transform missing')
  173. const source = transform('../src/client/GoalBar.tsx', clientSourceMapPath('client/ui-goal'))
  174. expect(source).toBe('../../../packages/client/ui-goal/src/client/GoalBar.tsx')
  175. const resolved = new URL(source, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-ui-goal/client.js.map')
  176. expect(resolved.pathname).toBe('/packages/client/ui-goal/src/client/GoalBar.tsx')
  177. })
  178. it('maps dual-face host sources to the host package group', () => {
  179. const configs = clientConfigs('@deepseek-ai/dsh-host-directory-picker-native')
  180. const outputOptions = configs[0]?.outputOptions
  181. if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
  182. const transform = outputOptions.sourcemapPathTransform
  183. if (transform === undefined) throw new Error('client sourcemap path transform missing')
  184. const source = transform('../src/client/index.ts', clientSourceMapPath('host/directory-picker-native'))
  185. expect(source).toBe('../../../packages/host/directory-picker-native/src/client/index.ts')
  186. })
  187. it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => {
  188. const configs = clientConfigs('@deepseek-ai/dsh-client-connection')
  189. const outputOptions = configs[0]?.outputOptions
  190. if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
  191. const transform = outputOptions.sourcemapPathTransform
  192. if (transform === undefined) throw new Error('client sourcemap path transform missing')
  193. const sourceMapPath = clientSourceMapPath('client/connection')
  194. const workspaceSource = transform('../src/rpc.ts', sourceMapPath)
  195. expect(workspaceSource).toBe('../../../packages/client/connection/src/rpc.ts')
  196. const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map')
  197. expect(resolved.pathname).toBe('/packages/client/connection/src/rpc.ts')
  198. const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js'
  199. expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource)
  200. })
  201. })
  202. describe('client bundle CSS Modules watch graph', () => {
  203. it('registers the physical stylesheet read behind a virtual module', async () => {
  204. const plugin = cssModulePlugin()
  205. const importer = fileURLToPath(new URL(
  206. '../packages/client/ui-conversation/src/client/queue/QueueDock.tsx',
  207. import.meta.url,
  208. ))
  209. const stylesheet = fileURLToPath(new URL(
  210. '../packages/client/ui-conversation/src/client/queue/QueueDock.module.css',
  211. import.meta.url,
  212. ))
  213. const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer)
  214. if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved')
  215. const addWatchFile = vi.fn()
  216. await plugin.load?.call({ addWatchFile }, virtualId)
  217. expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet)
  218. })
  219. })