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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { execFileSync } from 'node:child_process'
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join, resolve } from 'node:path'
  5. import yaml from 'js-yaml'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import {
  8. assertClientBuildEnvironment,
  9. clientBuildEnvironmentDefines,
  10. clientBuildProcessEnvironment,
  11. officialClientBuildEnvironment,
  12. readClientBuildRecord,
  13. repositoryClientBuildEnvironment,
  14. repositoryCommitHash,
  15. repositoryGitDirty,
  16. repositoryVersion,
  17. resolveClientBuildEnvironment,
  18. writeClientBuildRecord,
  19. } from './client-build-environment.ts'
  20. import { clientBundle } from '../packages/client/tsdown.client.ts'
  21. const root = resolve(import.meta.dirname, '..')
  22. const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST'
  23. const COMMIT_HASH = '0123456789abcdef0123456789abcdef01234567'
  24. const PROBE_KEY = `process.env.${PROBE_NAME}`
  25. const originalProbe = process.env[PROBE_NAME]
  26. const roots: string[] = []
  27. const dshBuildWorkflows = [
  28. 'build-exe-for-python-sdk.yml',
  29. 'ci.yml',
  30. 'e2b-e2e.yml',
  31. 'e2e.yml',
  32. 'release.yml',
  33. 'release-publish.yml',
  34. 'sandbox.yml',
  35. ]
  36. afterEach(() => {
  37. if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME)
  38. else process.env[PROBE_NAME] = originalProbe
  39. vi.resetModules()
  40. for (const fixtureRoot of roots.splice(0)) rmSync(fixtureRoot, { recursive: true, force: true })
  41. })
  42. function write(path: string, content: string): void {
  43. mkdirSync(dirname(path), { recursive: true })
  44. writeFileSync(path, content)
  45. }
  46. function buildFixture(environment: Record<string, string>): string {
  47. const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-'))
  48. roots.push(fixtureRoot)
  49. write(join(fixtureRoot, 'apps/web/dist/index.html'), '<main></main>')
  50. write(join(fixtureRoot, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
  51. writeClientBuildRecord(fixtureRoot, environment)
  52. return fixtureRoot
  53. }
  54. function git(root: string, args: readonly string[]): string {
  55. return execFileSync('git', [...args], {
  56. cwd: root,
  57. encoding: 'utf8',
  58. stdio: ['ignore', 'pipe', 'pipe'],
  59. }).trim()
  60. }
  61. function repositoryFixture(version = '1.2.3-rc.4'): string {
  62. const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-repository-'))
  63. roots.push(fixtureRoot)
  64. write(join(fixtureRoot, 'package.json'), `${JSON.stringify({ version })}\n`)
  65. write(join(fixtureRoot, 'tracked.txt'), 'committed\n')
  66. git(fixtureRoot, ['init'])
  67. git(fixtureRoot, ['config', 'user.name', 'DSH test'])
  68. git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid'])
  69. git(fixtureRoot, ['config', 'commit.gpgsign', 'false'])
  70. git(fixtureRoot, ['add', 'package.json', 'tracked.txt'])
  71. git(fixtureRoot, ['commit', '-m', 'fixture'])
  72. return fixtureRoot
  73. }
  74. describe('client build environment', () => {
  75. it('requires an exact public environment for a named artifact profile', () => {
  76. const expected = {
  77. DSH_CLIENT_BUILD_PROFILE: 'official',
  78. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  79. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  80. DSH_CLIENT_VERSION: '1.2.3',
  81. } as const
  82. expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow()
  83. expect(() => { assertClientBuildEnvironment({}, expected) }).toThrow(/DSH_CLIENT_TITLE/)
  84. expect(() => { assertClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Other' }, expected) }).toThrow(/DSH_CLIENT_TITLE/)
  85. expect(() => {
  86. assertClientBuildEnvironment({ ...expected, DSH_CLIENT_UNDECLARED: 'value' }, expected)
  87. }).toThrow(/DSH_CLIENT_UNDECLARED/)
  88. })
  89. it('inherits public values by default and isolates an explicit official profile', () => {
  90. const parent = {
  91. PATH: '/bin',
  92. DSH_BUILD_CLIENT_PROFILE: 'official',
  93. DSH_CLIENT_BUILD_PROFILE: 'local',
  94. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  95. DSH_CLIENT_GIT_DIRTY: 'true',
  96. DSH_CLIENT_TITLE: 'Local title',
  97. DSH_CLIENT_VERSION: '1.2.3',
  98. DSH_CLIENT_EXTRA: 'local-extra',
  99. }
  100. expect(resolveClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Local title' })).toEqual({
  101. DSH_CLIENT_TITLE: 'Local title',
  102. })
  103. expect(resolveClientBuildEnvironment(parent)).toEqual({
  104. DSH_CLIENT_BUILD_PROFILE: 'official',
  105. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  106. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  107. DSH_CLIENT_VERSION: '1.2.3',
  108. })
  109. expect(() => {
  110. resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' })
  111. }).toThrow(/DSH_CLIENT_COMMIT_HASH/)
  112. expect(() => {
  113. resolveClientBuildEnvironment({
  114. DSH_BUILD_CLIENT_PROFILE: 'official',
  115. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  116. })
  117. }).toThrow(/DSH_CLIENT_VERSION/)
  118. expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/)
  119. expect(clientBuildProcessEnvironment(parent, {
  120. DSH_CLIENT_BUILD_PROFILE: 'official',
  121. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  122. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  123. DSH_CLIENT_VERSION: '1.2.3',
  124. })).toEqual({
  125. PATH: '/bin',
  126. DSH_CLIENT_BUILD_PROFILE: 'official',
  127. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  128. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  129. DSH_CLIENT_VERSION: '1.2.3',
  130. })
  131. expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7))
  132. })
  133. it('owns repository version, commit, and dirty metadata for complete builds', () => {
  134. const fixtureRoot = repositoryFixture()
  135. const commit = git(fixtureRoot, ['rev-parse', '--short=7', 'HEAD'])
  136. expect(repositoryVersion(fixtureRoot)).toBe('1.2.3-rc.4')
  137. expect(repositoryGitDirty(fixtureRoot)).toBe(false)
  138. expect(repositoryClientBuildEnvironment(fixtureRoot, {
  139. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
  140. DSH_CLIENT_EXTRA: 'preserved',
  141. DSH_CLIENT_GIT_DIRTY: 'true',
  142. DSH_CLIENT_VERSION: 'spoofed',
  143. })).toEqual({
  144. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  145. DSH_CLIENT_EXTRA: 'preserved',
  146. DSH_CLIENT_VERSION: '1.2.3-rc.4',
  147. })
  148. expect(officialClientBuildEnvironment(fixtureRoot)).toEqual({
  149. DSH_CLIENT_BUILD_PROFILE: 'official',
  150. DSH_CLIENT_COMMIT_HASH: commit,
  151. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  152. DSH_CLIENT_VERSION: '1.2.3-rc.4',
  153. })
  154. write(join(fixtureRoot, '.gitignore'), 'ignored.txt\n')
  155. git(fixtureRoot, ['add', '.gitignore'])
  156. git(fixtureRoot, ['commit', '-m', 'ignore fixture'])
  157. write(join(fixtureRoot, 'ignored.txt'), 'ignored\n')
  158. expect(repositoryGitDirty(fixtureRoot)).toBe(false)
  159. rmSync(join(fixtureRoot, 'ignored.txt'))
  160. write(join(fixtureRoot, 'tracked.txt'), 'unstaged\n')
  161. expect(repositoryGitDirty(fixtureRoot)).toBe(true)
  162. write(join(fixtureRoot, 'tracked.txt'), 'committed\n')
  163. expect(repositoryGitDirty(fixtureRoot)).toBe(false)
  164. write(join(fixtureRoot, 'tracked.txt'), 'staged\n')
  165. git(fixtureRoot, ['add', 'tracked.txt'])
  166. expect(repositoryGitDirty(fixtureRoot)).toBe(true)
  167. git(fixtureRoot, ['commit', '-m', 'staged fixture'])
  168. expect(repositoryGitDirty(fixtureRoot)).toBe(false)
  169. write(join(fixtureRoot, 'untracked.txt'), 'untracked\n')
  170. expect(repositoryGitDirty(fixtureRoot)).toBe(true)
  171. expect(repositoryClientBuildEnvironment(fixtureRoot, {
  172. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
  173. })).toEqual({
  174. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  175. DSH_CLIENT_GIT_DIRTY: 'true',
  176. DSH_CLIENT_VERSION: '1.2.3-rc.4',
  177. })
  178. rmSync(join(fixtureRoot, 'untracked.txt'))
  179. const submoduleSource = repositoryFixture('9.8.7')
  180. git(fixtureRoot, ['-c', 'protocol.file.allow=always', 'submodule', 'add', submoduleSource, 'submodule'])
  181. git(fixtureRoot, ['commit', '-am', 'submodule fixture'])
  182. expect(repositoryGitDirty(fixtureRoot)).toBe(false)
  183. write(join(fixtureRoot, 'submodule/tracked.txt'), 'modified submodule\n')
  184. expect(repositoryGitDirty(fixtureRoot)).toBe(true)
  185. })
  186. it('omits dirty metadata when repository metadata is unavailable', () => {
  187. const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-no-git-'))
  188. roots.push(fixtureRoot)
  189. write(join(fixtureRoot, 'package.json'), '{"version":"2.0.0"}\n')
  190. expect(repositoryGitDirty(fixtureRoot)).toBeUndefined()
  191. expect(repositoryClientBuildEnvironment(fixtureRoot, {
  192. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
  193. DSH_CLIENT_GIT_DIRTY: 'true',
  194. })).toEqual({
  195. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  196. DSH_CLIENT_VERSION: '2.0.0',
  197. })
  198. })
  199. it('defines only public client values over a non-enumerable fallback', () => {
  200. expect(clientBuildEnvironmentDefines({
  201. PATH: '/bin',
  202. DSH_TEST_API_KEY: 'secret',
  203. DSH_CLIENT_VARIANT: 'quoted "value"',
  204. DSH_CLIENT_EMPTY: '',
  205. DSH_CLIENT_UNSET: undefined,
  206. })).toEqual({
  207. 'process.env': '{}',
  208. 'process.env.DSH_CLIENT_EMPTY': '""',
  209. 'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""',
  210. })
  211. })
  212. it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => {
  213. process.env[PROBE_NAME] = 'shared-value'
  214. const configs = clientBundle('@deepseek-ai/dsh-client-ui-sidebar', [
  215. 'lib/types/index.js',
  216. 'lib/types/invariant.js',
  217. ])({ env: { DSH_BUILD_FACE: 'client' } })
  218. if (!Array.isArray(configs)) throw new TypeError('client bundle config must be an array')
  219. const dynamic = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar/client')
  220. expect(dynamic?.define).toMatchObject({
  221. 'process.env': '{}',
  222. [PROBE_KEY]: '"shared-value"',
  223. })
  224. const viteConfigPath = '../apps/web/vite.config.ts'
  225. const viteModule: unknown = await import(viteConfigPath)
  226. if (typeof viteModule !== 'object' || viteModule === null) {
  227. throw new TypeError('web Vite config module must be an object')
  228. }
  229. const viteConfig: unknown = Reflect.get(viteModule, 'default')
  230. if (typeof viteConfig === 'function') throw new TypeError('web Vite config must be an object')
  231. if (typeof viteConfig !== 'object' || viteConfig === null) {
  232. throw new TypeError('web Vite config must be an object')
  233. }
  234. expect(Reflect.get(viteConfig, 'define')).toMatchObject({
  235. 'process.env': '{}',
  236. [PROBE_KEY]: '"shared-value"',
  237. })
  238. })
  239. it('binds the recorded environment to a complete set of client artifacts', () => {
  240. const officialEnvironment = {
  241. DSH_CLIENT_BUILD_PROFILE: 'official',
  242. DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
  243. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  244. DSH_CLIENT_VERSION: '1.2.3',
  245. }
  246. const official = buildFixture(officialEnvironment)
  247. const defaultBuild = buildFixture({})
  248. expect(readClientBuildRecord(official, officialEnvironment).environment).toEqual(officialEnvironment)
  249. expect(() => { readClientBuildRecord(defaultBuild, officialEnvironment) }).toThrow(/DSH_CLIENT_/)
  250. expect(() => { readClientBuildRecord(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/)
  251. write(join(official, 'apps/web/dist/index.html'), '<main>changed</main>')
  252. expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/)
  253. })
  254. it('keeps public client values out of workflow-wide environments', () => {
  255. for (const name of dshBuildWorkflows) {
  256. const path = `.github/workflows/${name}`
  257. const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
  258. if (typeof document !== 'object' || document === null || Array.isArray(document)) {
  259. throw new TypeError(`${path} must contain a workflow object`)
  260. }
  261. expect(JSON.stringify(document), path).not.toContain('DSH_CLIENT_')
  262. }
  263. })
  264. })