client-bundle-purity.spec.ts 9.8 KB

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