client-bundle-purity.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. /**
  2. * Pins shared client-bundle preset contracts: 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 { CLIENT_EXTERNALS, clientBundle } 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. function clientSourceMapPath(packagePath: string): string {
  15. return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
  16. }
  17. function purityResolveId(): ResolveId {
  18. // libEntry is spelled at every call site (no default) so the
  19. // package-invariants text check can see the invariant entry per package.
  20. const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
  21. const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
  22. const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
  23. if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
  24. return gate.resolveId as ResolveId
  25. }
  26. function cssModulePlugin(): CssModulePlugin {
  27. const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
  28. const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins
  29. const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
  30. if (plugin?.resolveId === undefined || plugin.load === undefined) {
  31. throw new Error('CSS Modules plugin missing from client config')
  32. }
  33. return plugin
  34. }
  35. describe('client bundle purity gate', () => {
  36. const resolveId = purityResolveId()
  37. it('leaves platform table entries and non-scoped specifiers alone', () => {
  38. expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
  39. expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull()
  40. expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
  41. expect(resolveId('react')).toBeNull()
  42. expect(resolveId('zod')).toBeNull()
  43. })
  44. it('rejects retired table entries (web-react/store left the 8-entry seed)', () => {
  45. expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
  46. })
  47. it('lets inline-safe wire layers inline', () => {
  48. expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
  49. expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
  50. expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
  51. })
  52. it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
  53. expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
  54. expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
  55. expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/)
  56. expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/)
  57. })
  58. it('throws on any other @deepseek-ai leak', () => {
  59. expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
  60. expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
  61. })
  62. it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
  63. expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
  64. expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
  65. expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
  66. })
  67. it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
  68. expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
  69. const dshClientChannels = CLIENT_EXTERNALS.filter(
  70. entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
  71. expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
  72. })
  73. })
  74. describe('client bundle debug artifacts', () => {
  75. it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
  76. const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
  77. expect(configs[1]?.sourcemap).toBe(true)
  78. })
  79. it('maps first-party sources to their repository package paths', () => {
  80. const configs = clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js'])
  81. const outputOptions = configs[1]?.outputOptions
  82. if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
  83. const transform = outputOptions.sourcemapPathTransform
  84. if (transform === undefined) throw new Error('client sourcemap path transform missing')
  85. const source = transform('../src/client/GoalBar.tsx', clientSourceMapPath('client/ui-goal'))
  86. expect(source).toBe('../../../packages/client/ui-goal/src/client/GoalBar.tsx')
  87. const resolved = new URL(source, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-ui-goal/client.js.map')
  88. expect(resolved.pathname).toBe('/packages/client/ui-goal/src/client/GoalBar.tsx')
  89. })
  90. it('maps dual-face host sources to the host package group', () => {
  91. const configs = clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js'])
  92. const outputOptions = configs[1]?.outputOptions
  93. if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
  94. const transform = outputOptions.sourcemapPathTransform
  95. if (transform === undefined) throw new Error('client sourcemap path transform missing')
  96. const source = transform('../src/client/index.ts', clientSourceMapPath('host/directory-picker-native'))
  97. expect(source).toBe('../../../packages/host/directory-picker-native/src/client/index.ts')
  98. })
  99. it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => {
  100. const configs = clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js'])
  101. const outputOptions = configs[1]?.outputOptions
  102. if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
  103. const transform = outputOptions.sourcemapPathTransform
  104. if (transform === undefined) throw new Error('client sourcemap path transform missing')
  105. const sourceMapPath = clientSourceMapPath('client/connection')
  106. const workspaceSource = transform('../../../host/apiproxy/src/api/rpc.ts', sourceMapPath)
  107. expect(workspaceSource).toBe('../../../packages/host/apiproxy/src/api/rpc.ts')
  108. const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map')
  109. expect(resolved.pathname).toBe('/packages/host/apiproxy/src/api/rpc.ts')
  110. const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js'
  111. expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource)
  112. })
  113. })
  114. describe('client bundle CSS Modules watch graph', () => {
  115. it('registers the physical stylesheet read behind a virtual module', async () => {
  116. const plugin = cssModulePlugin()
  117. const importer = fileURLToPath(new URL(
  118. '../packages/client/ui-conversation/src/client/queue/QueueDock.tsx',
  119. import.meta.url,
  120. ))
  121. const stylesheet = fileURLToPath(new URL(
  122. '../packages/client/ui-conversation/src/client/queue/QueueDock.module.css',
  123. import.meta.url,
  124. ))
  125. const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer)
  126. if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved')
  127. const addWatchFile = vi.fn()
  128. await plugin.load?.call({ addWatchFile }, virtualId)
  129. expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet)
  130. })
  131. })