install-script.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  2. import { mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { execa } from 'execa'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
  9. const fixtures: string[] = []
  10. const PTY_DRIVER = String.raw`
  11. import errno, json, os, pty, select, signal, sys, time
  12. script, cwd, env_json, actions_json = sys.argv[1:]
  13. env = os.environ.copy()
  14. env.update(json.loads(env_json))
  15. actions = json.loads(actions_json)
  16. pid, fd = pty.fork()
  17. if pid == 0:
  18. os.chdir(cwd)
  19. os.execvpe("sh", ["sh", script], env)
  20. output = bytearray()
  21. action_index = 0
  22. deadline = time.monotonic() + 15
  23. status = None
  24. while time.monotonic() < deadline:
  25. ready, _, _ = select.select([fd], [], [], 0.05)
  26. if ready:
  27. try:
  28. chunk = os.read(fd, 65536)
  29. except OSError as error:
  30. if error.errno != errno.EIO:
  31. raise
  32. chunk = b""
  33. output.extend(chunk)
  34. while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
  35. os.write(fd, actions[action_index]["send"].encode())
  36. action_index += 1
  37. waited, candidate = os.waitpid(pid, os.WNOHANG)
  38. if waited == pid:
  39. status = candidate
  40. break
  41. if status is None:
  42. os.kill(pid, signal.SIGKILL)
  43. _, status = os.waitpid(pid, 0)
  44. sys.stdout.buffer.write(output)
  45. if action_index != len(actions):
  46. sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
  47. sys.exit(124)
  48. sys.exit(os.waitstatus_to_exitcode(status))
  49. `
  50. interface Action {
  51. readonly waitFor: string
  52. readonly send: string
  53. }
  54. interface Fixture {
  55. readonly binDirectory: string
  56. readonly launchLog: string
  57. readonly pnpmLog: string
  58. readonly root: string
  59. readonly script: string
  60. }
  61. afterEach(async () => {
  62. await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
  63. })
  64. function executable(path: string, content: string): void {
  65. writeFileSync(path, content)
  66. chmodSync(path, 0o755)
  67. }
  68. async function createFixture(): Promise<Fixture> {
  69. const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
  70. fixtures.push(root)
  71. const checkoutDirectory = join(root, 'checkout')
  72. const scriptsDirectory = join(checkoutDirectory, 'scripts')
  73. const sourceBinDirectory = join(checkoutDirectory, 'bin')
  74. const fakeBinDirectory = join(root, 'fake-bin')
  75. const binDirectory = join(root, 'path-bin')
  76. for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
  77. mkdirSync(directory, { recursive: true })
  78. }
  79. const script = join(scriptsDirectory, 'install.sh')
  80. copyFileSync(installer, script)
  81. const launchLog = join(root, 'launch.log')
  82. const pnpmLog = join(root, 'pnpm.log')
  83. executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
  84. executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
  85. if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
  86. printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
  87. `)
  88. await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
  89. await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
  90. await execa('git', [
  91. '-c', 'user.name=dsh-test',
  92. '-c', 'user.email=dsh-test@example.invalid',
  93. 'commit', '-qm', 'fixture',
  94. ], { cwd: checkoutDirectory })
  95. writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
  96. return { binDirectory, launchLog, pnpmLog, root, script }
  97. }
  98. async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
  99. const result = await execa('python3', [
  100. '-c',
  101. PTY_DRIVER,
  102. fixture.script,
  103. fixture.root,
  104. JSON.stringify({
  105. DSH_BIN_DIR: fixture.binDirectory,
  106. DSH_HOME: join(fixture.root, 'home/.dsh'),
  107. DSH_TEST_LAUNCH_LOG: fixture.launchLog,
  108. DSH_TEST_PNPM_LOG: fixture.pnpmLog,
  109. HOME: join(fixture.root, 'home'),
  110. PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
  111. }),
  112. JSON.stringify(actions),
  113. ], { reject: false, stripFinalNewline: false, timeout: 20_000 })
  114. expect(result.exitCode, result.stderr).toBe(0)
  115. return result.stdout
  116. }
  117. describe.runIf(process.platform !== 'win32')('one-line installer launch', { timeout: 25_000 }, () => {
  118. it('builds and launches the Web UI', async () => {
  119. const fixture = await createFixture()
  120. const output = await runInstaller(fixture, [
  121. { waitFor: 'Replace it?', send: '\n' },
  122. ])
  123. expect(output).toContain('launching Web UI')
  124. expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
  125. expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
  126. })
  127. })