client-build-environment.client.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join, resolve } from 'node:path'
  4. import yaml from 'js-yaml'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import {
  7. assertClientBuildEnvironment,
  8. clientBuildEnvironmentDefines,
  9. clientBuildProcessEnvironment,
  10. readClientBuildRecord,
  11. repositoryCommitHash,
  12. resolveClientBuildEnvironment,
  13. writeClientBuildRecord,
  14. } from './client-build-environment.ts'
  15. import { clientBundle } from '../packages/client/tsdown.client.ts'
  16. const root = resolve(import.meta.dirname, '..')
  17. const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST'
  18. const COMMIT_HASH = '0123456789abcdef0123456789abcdef01234567'
  19. const PROBE_KEY = `process.env.${PROBE_NAME}`
  20. const originalProbe = process.env[PROBE_NAME]
  21. const roots: string[] = []
  22. const dshBuildWorkflows = [
  23. 'build-exe-for-python-sdk.yml',
  24. 'ci.yml',
  25. 'e2b-e2e.yml',
  26. 'e2e.yml',
  27. 'release.yml',
  28. 'release-publish.yml',
  29. 'sandbox.yml',
  30. ]
  31. afterEach(() => {
  32. if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME)
  33. else process.env[PROBE_NAME] = originalProbe
  34. vi.resetModules()
  35. for (const fixtureRoot of roots.splice(0)) rmSync(fixtureRoot, { recursive: true, force: true })
  36. })
  37. function write(path: string, content: string): void {
  38. mkdirSync(dirname(path), { recursive: true })
  39. writeFileSync(path, content)
  40. }
  41. function buildFixture(environment: Record<string, string>): string {
  42. const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-'))
  43. roots.push(fixtureRoot)
  44. write(join(fixtureRoot, 'apps/web/dist/index.html'), '<main></main>')
  45. write(join(fixtureRoot, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
  46. writeClientBuildRecord(fixtureRoot, environment)
  47. return fixtureRoot
  48. }
  49. describe('client build environment', () => {
  50. it('requires an exact public environment for a named artifact profile', () => {
  51. const expected = {
  52. DSH_CLIENT_BUILD_PROFILE: 'official',
  53. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  54. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  55. } as const
  56. expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow()
  57. expect(() => { assertClientBuildEnvironment({}, expected) }).toThrow(/DSH_CLIENT_TITLE/)
  58. expect(() => { assertClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Other' }, expected) }).toThrow(/DSH_CLIENT_TITLE/)
  59. expect(() => {
  60. assertClientBuildEnvironment({ ...expected, DSH_CLIENT_UNDECLARED: 'value' }, expected)
  61. }).toThrow(/DSH_CLIENT_UNDECLARED/)
  62. })
  63. it('inherits public values by default and isolates an explicit official profile', () => {
  64. const parent = {
  65. PATH: '/bin',
  66. DSH_BUILD_CLIENT_PROFILE: 'official',
  67. DSH_CLIENT_BUILD_PROFILE: 'local',
  68. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  69. DSH_CLIENT_TITLE: 'Local title',
  70. DSH_CLIENT_EXTRA: 'local-extra',
  71. }
  72. expect(resolveClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Local title' })).toEqual({
  73. DSH_CLIENT_TITLE: 'Local title',
  74. })
  75. expect(resolveClientBuildEnvironment(parent)).toEqual({
  76. DSH_CLIENT_BUILD_PROFILE: 'official',
  77. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  78. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  79. })
  80. expect(() => {
  81. resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' })
  82. }).toThrow(/DSH_CLIENT_COMMIT_HASH/)
  83. expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/)
  84. expect(clientBuildProcessEnvironment(parent, {
  85. DSH_CLIENT_BUILD_PROFILE: 'official',
  86. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  87. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  88. })).toEqual({
  89. PATH: '/bin',
  90. DSH_CLIENT_BUILD_PROFILE: 'official',
  91. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  92. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  93. })
  94. expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7))
  95. })
  96. it('defines only public client values over a non-enumerable fallback', () => {
  97. expect(clientBuildEnvironmentDefines({
  98. PATH: '/bin',
  99. DSH_TEST_API_KEY: 'secret',
  100. DSH_CLIENT_VARIANT: 'quoted "value"',
  101. DSH_CLIENT_EMPTY: '',
  102. DSH_CLIENT_UNSET: undefined,
  103. })).toEqual({
  104. 'process.env': '{}',
  105. 'process.env.DSH_CLIENT_EMPTY': '""',
  106. 'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""',
  107. })
  108. })
  109. it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => {
  110. process.env[PROBE_NAME] = 'shared-value'
  111. const configs = clientBundle('@deepseek-ai/dsh-client-ui-sidebar', [
  112. 'lib/types/index.js',
  113. 'lib/types/invariant.js',
  114. ])({ env: { DSH_BUILD_FACE: 'client' } })
  115. if (!Array.isArray(configs)) throw new TypeError('client bundle config must be an array')
  116. const dynamic = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar/client')
  117. expect(dynamic?.define).toMatchObject({
  118. 'process.env': '{}',
  119. [PROBE_KEY]: '"shared-value"',
  120. })
  121. const viteConfigPath = '../apps/web/vite.config.ts'
  122. const viteModule: unknown = await import(viteConfigPath)
  123. if (typeof viteModule !== 'object' || viteModule === null) {
  124. throw new TypeError('web Vite config module must be an object')
  125. }
  126. const viteConfig: unknown = Reflect.get(viteModule, 'default')
  127. if (typeof viteConfig === 'function') throw new TypeError('web Vite config must be an object')
  128. if (typeof viteConfig !== 'object' || viteConfig === null) {
  129. throw new TypeError('web Vite config must be an object')
  130. }
  131. expect(Reflect.get(viteConfig, 'define')).toMatchObject({
  132. 'process.env': '{}',
  133. [PROBE_KEY]: '"shared-value"',
  134. })
  135. })
  136. it('binds the recorded environment to a complete set of client artifacts', () => {
  137. const officialEnvironment = {
  138. DSH_CLIENT_BUILD_PROFILE: 'official',
  139. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  140. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  141. }
  142. const official = buildFixture(officialEnvironment)
  143. const defaultBuild = buildFixture({})
  144. expect(readClientBuildRecord(official, officialEnvironment).environment).toEqual(officialEnvironment)
  145. expect(() => { readClientBuildRecord(defaultBuild, officialEnvironment) }).toThrow(/DSH_CLIENT_/)
  146. expect(() => { readClientBuildRecord(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/)
  147. write(join(official, 'apps/web/dist/index.html'), '<main>changed</main>')
  148. expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/)
  149. })
  150. it('keeps public client values out of workflow-wide environments', () => {
  151. for (const name of dshBuildWorkflows) {
  152. const path = `.github/workflows/${name}`
  153. const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
  154. if (typeof document !== 'object' || document === null || Array.isArray(document)) {
  155. throw new TypeError(`${path} must contain a workflow object`)
  156. }
  157. expect(JSON.stringify(document), path).not.toContain('DSH_CLIENT_')
  158. }
  159. })
  160. })