python-runtime-selfhosted.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. import { spawnSync } from 'node:child_process'
  2. import { readFileSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { runInNewContext } from 'node:vm'
  5. import * as yaml from 'js-yaml'
  6. import { describe, expect, it } from 'vitest'
  7. const root = resolve(import.meta.dirname, '..')
  8. const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8')) as {
  9. jobs: Record<string, { 'runs-on': string; steps: Array<{ name?: string; id?: string; uses?: string; if?: string; shell?: string; run?: string; with?: Record<string, unknown> }> }>
  10. }
  11. const build = workflow.jobs.build!
  12. const selector = build['runs-on'].slice(3, -2).trim()
  13. const windows = ['self-hosted', 'dsh-win-ci', 'windows', 'x64']
  14. function context() {
  15. return {
  16. inputs: { ci: true, release: false },
  17. github: {
  18. repository: 'deepseek-harness/deepseek-harness',
  19. event_name: 'pull_request',
  20. ref: 'refs/pull/42/merge',
  21. event: { pull_request: {
  22. head: { repo: { full_name: 'deepseek-harness/deepseek-harness', fork: false } },
  23. user: { login: 'contributor' },
  24. } },
  25. },
  26. matrix: { target: 'node24-win-x64', runner: 'windows-2025' },
  27. vars: { DSH_CI_FAILOVER_WINDOWS: 'selfhosted' },
  28. fromJSON: JSON.parse,
  29. }
  30. }
  31. function route(value: ReturnType<typeof context>, expression = selector): unknown {
  32. // The workflow uses only comparisons, booleans and fromJSON; execute that exact expression.
  33. return runInNewContext(expression, value, { timeout: 1000 })
  34. }
  35. describe('Python runtime self-hosted routing', () => {
  36. it('routes same-repository member PRs and master CI to native x64 Windows', () => {
  37. expect(route(context())).toEqual(windows)
  38. const master = context()
  39. master.github.event_name = 'push'
  40. master.github.ref = 'refs/heads/master'
  41. expect(route(master)).toEqual(windows)
  42. })
  43. it.each([
  44. ['release caller', (value: ReturnType<typeof context>) => { value.inputs.release = true }],
  45. ['non-CI caller', (value: ReturnType<typeof context>) => { value.inputs.ci = false }],
  46. ['manual dispatch', (value: ReturnType<typeof context>) => { value.github.event_name = 'workflow_dispatch' }],
  47. ['pull_request_target', (value: ReturnType<typeof context>) => { value.github.event_name = 'pull_request_target' }],
  48. ['unknown event', (value: ReturnType<typeof context>) => { value.github.event_name = '' }],
  49. ['fork', (value: ReturnType<typeof context>) => { value.github.event.pull_request.head.repo.fork = true }],
  50. ['different repository head', (value: ReturnType<typeof context>) => { value.github.event.pull_request.head.repo.full_name = 'someone/fork' }],
  51. ['different caller repository', (value: ReturnType<typeof context>) => { value.github.repository = 'someone/fork' }],
  52. ['Dependabot author', (value: ReturnType<typeof context>) => { value.github.event.pull_request.user.login = 'dependabot[bot]' }],
  53. ['disabled failover', (value: ReturnType<typeof context>) => { value.vars.DSH_CI_FAILOVER_WINDOWS = '' }],
  54. ['unknown failover value', (value: ReturnType<typeof context>) => { value.vars.DSH_CI_FAILOVER_WINDOWS = 'hosted' }],
  55. ['branch push', (value: ReturnType<typeof context>) => { value.github.event_name = 'push'; value.github.ref = 'refs/heads/topic' }],
  56. ['tag push', (value: ReturnType<typeof context>) => { value.github.event_name = 'push'; value.github.ref = 'refs/tags/python-v1' }],
  57. ] as const)('keeps %s on the hosted fallback', (_name, change) => {
  58. const value = context()
  59. change(value)
  60. expect(route(value)).toBe('windows-2025')
  61. })
  62. it.each([
  63. ['node24-linux-x64', 'ubuntu-latest'],
  64. ['node24-linux-arm64', 'ubuntu-24.04-arm'],
  65. ['node24-macos-arm64', 'macos-latest'],
  66. ['node24-macos-x64', 'macos-15-intel'],
  67. ])('keeps %s hosted even with failover enabled', (target, runner) => {
  68. const value = context()
  69. value.matrix = { target, runner }
  70. expect(route(value)).toBe(runner)
  71. })
  72. it('keeps setup helper jobs on hosted images', () => {
  73. expect(workflow.jobs.plan!['runs-on']).toBe('ubuntu-latest')
  74. expect(workflow.jobs['sdk-wheel']!['runs-on']).toBe('ubuntu-latest')
  75. })
  76. it('isolates setup before pnpm and excludes shared installers and cache archives', () => {
  77. const privateSetup = build.steps.findIndex(step => step.id === 'private-windows')
  78. expect(privateSetup).toBeGreaterThan(0)
  79. expect(privateSetup).toBeLessThan(build.steps.findIndex(step => step.uses?.startsWith('pnpm/action-setup@')))
  80. for (const step of build.steps.filter(step => step.uses?.startsWith('actions/setup-python@') || step.uses?.startsWith('actions/cache@') || step.name === 'Install Python build tooling')) {
  81. expect(step.if).toBe("runner.environment != 'self-hosted'")
  82. }
  83. expect(build.steps.find(step => step.name?.startsWith('Enable Windows'))?.if).toBe("runner.os == 'Windows' && runner.environment != 'self-hosted'")
  84. expect(build.steps.find(step => step.uses?.startsWith('actions/setup-node@'))?.with?.cache).toContain("runner.environment != 'self-hosted'")
  85. expect(build.steps.at(-1)).toMatchObject({ if: "always() && steps.private-windows.outputs.root != ''", shell: 'pwsh' })
  86. const cleanup = build.steps.at(-1)!.run!
  87. expect(cleanup).toContain('"NODE_COMPILE_CACHE=" >> $env:GITHUB_ENV')
  88. expect(cleanup).toContain('"TMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV')
  89. expect(cleanup).toContain('"TEMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV')
  90. expect(cleanup).toContain('maxRetries: 10, retryDelay: 100')
  91. })
  92. it('reads UTF-8 Session JSONL independently of the host locale', () => {
  93. const setup = readFileSync(resolve(root, 'scripts/setup-python-runtime-windows.ps1'), 'utf8')
  94. const utf8 = /PYTHONUTF8 = '([^']+)'/.exec(setup)?.[1]
  95. expect(utf8).toBe('1')
  96. const result = spawnSync(process.platform === 'win32' ? 'python' : 'python3', ['-c', [
  97. 'import pathlib, tempfile, sys',
  98. 'assert sys.flags.utf8_mode == 1',
  99. 'with tempfile.TemporaryDirectory(prefix="python-runtime-encoding-") as root:',
  100. ' log = pathlib.Path(root) / "session.jsonl"',
  101. ' text = chr(0x2014) + chr(0x4e2d)',
  102. ' log.write_bytes(text.encode("utf-8"))',
  103. ' assert log.read_text() == text',
  104. ].join('\n')], {
  105. env: { ...process.env, LC_ALL: 'C', LANG: 'C', PYTHONCOERCECLOCALE: '0', PYTHONUTF8: utf8 },
  106. encoding: 'utf8',
  107. timeout: 10000,
  108. })
  109. expect(result.error).toBeUndefined()
  110. expect(result.signal).toBeNull()
  111. expect(result.status, result.stderr).toBe(0)
  112. })
  113. it('pins portable Python without registry or shared cache writes', () => {
  114. const setup = readFileSync(resolve(root, 'scripts/setup-python-runtime-windows.ps1'), 'utf8')
  115. expect(setup).toContain('--no-bin --no-registry 3.10')
  116. expect(setup).toContain('--managed-python --no-python-downloads --seed')
  117. expect(setup).toContain('UV_PYTHON_INSTALL_REGISTRY')
  118. expect(setup).toContain('PNPM_CONFIG_STORE_DIR')
  119. expect(setup).toContain('PKG_CACHE_PATH')
  120. expect(setup).not.toMatch(/reg add|Set-ItemProperty|InstallAllUsers/)
  121. })
  122. })