client-bundle-purity.spec.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  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 purityResolveId(): ResolveId {
  15. // libEntry is spelled at every call site (no default) so the
  16. // package-invariants text check can see the invariant entry per package.
  17. const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
  18. const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
  19. const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
  20. if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
  21. return gate.resolveId as ResolveId
  22. }
  23. function cssModulePlugin(): CssModulePlugin {
  24. const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
  25. const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins
  26. const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
  27. if (plugin?.resolveId === undefined || plugin.load === undefined) {
  28. throw new Error('CSS Modules plugin missing from client config')
  29. }
  30. return plugin
  31. }
  32. describe('client bundle purity gate', () => {
  33. const resolveId = purityResolveId()
  34. it('leaves platform table entries and non-scoped specifiers alone', () => {
  35. expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
  36. expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull()
  37. expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
  38. expect(resolveId('react')).toBeNull()
  39. expect(resolveId('zod')).toBeNull()
  40. })
  41. it('rejects retired table entries (web-react/store left the 8-entry seed)', () => {
  42. expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
  43. })
  44. it('lets inline-safe wire layers inline', () => {
  45. expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
  46. expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
  47. expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
  48. })
  49. it('throws on any other @deepseek-ai leak', () => {
  50. expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
  51. expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
  52. })
  53. it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
  54. expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
  55. expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
  56. expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
  57. })
  58. it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
  59. expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
  60. const dshClientChannels = CLIENT_EXTERNALS.filter(
  61. entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
  62. expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
  63. })
  64. })
  65. describe('client bundle CSS Modules watch graph', () => {
  66. it('registers the physical stylesheet read behind a virtual module', async () => {
  67. const plugin = cssModulePlugin()
  68. const importer = fileURLToPath(new URL(
  69. '../packages/client/ui-conversation/src/client/queue/QueueDock.tsx',
  70. import.meta.url,
  71. ))
  72. const stylesheet = fileURLToPath(new URL(
  73. '../packages/client/ui-conversation/src/client/queue/QueueDock.module.css',
  74. import.meta.url,
  75. ))
  76. const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer)
  77. if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved')
  78. const addWatchFile = vi.fn()
  79. await plugin.load?.call({ addWatchFile }, virtualId)
  80. expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet)
  81. })
  82. })