shell-activity.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /** Real interactive shells preserve startup files and distinguish prompts from running jobs. */
  2. import { existsSync } from 'node:fs'
  3. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. import LocalSubprocessRuntime from '../src/index.ts'
  9. const cleanups: Array<() => Promise<void>> = []
  10. afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
  11. async function shell(path: string, rc = '', envFile = '') {
  12. const home = await mkdtemp(join(tmpdir(), 'dsh-shell-activity-test-'))
  13. cleanups.push(() => rm(home, { recursive: true, force: true }))
  14. await writeFile(join(home, '.zshenv'), envFile)
  15. await writeFile(join(home, '.zshrc'), `PROMPT='READY> '\n${rc}\n`)
  16. await writeFile(join(home, '.bashrc'), `PS1='READY> '\n${rc}\n`)
  17. const ctx = new Context()
  18. cleanups.push(async () => { await ctx.fiber.dispose() })
  19. await ctx.plugin(LocalSubprocessRuntime)
  20. const handle = await ctx.subprocess.spawnTerminal({ argv: [path, '-i'], cwd: home, env: { HOME: home, ZDOTDIR: home }, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 200, shellActivity: true })
  21. cleanups.push(() => handle.terminate())
  22. let output = ''
  23. handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') })
  24. await expect.poll(() => output).toContain('READY>')
  25. return { handle, home, output: () => output, activity: async () => (await handle.inspectActivity()).state }
  26. }
  27. describe.skipIf(process.platform === 'win32' || !existsSync('/bin/zsh'))('Zsh terminal activity', () => {
  28. it('keeps silent foreground work, builtin loops, read, and background jobs busy until a new prompt is idle', async () => {
  29. const h = await shell('/bin/zsh')
  30. await expect.poll(h.activity).toBe('idle')
  31. for (const command of ['sleep 600', 'while :; do :; done', 'read answer', 'sleep 600 &']) {
  32. await h.handle.write(`${command}\r`)
  33. await expect.poll(h.activity).toBe('busy')
  34. await h.handle.write(command.endsWith('&') ? 'kill %1\r' : '\x03')
  35. await expect.poll(h.activity).toBe('idle')
  36. }
  37. })
  38. it('does not confuse vared or multiline editing with a top-level empty prompt', async () => {
  39. const h = await shell('/bin/zsh')
  40. await expect.poll(h.activity).toBe('idle')
  41. await h.handle.write('value=abc; vared value\r')
  42. await expect.poll(h.activity).toBe('busy')
  43. await h.handle.write('\x03')
  44. await expect.poll(h.activity).toBe('idle')
  45. await h.handle.write('if true; then\r')
  46. await expect.poll(h.activity).toBe('busy')
  47. await h.handle.write('fi\r')
  48. await expect.poll(h.activity).toBe('idle')
  49. await h.handle.write('partial')
  50. expect(await h.activity()).toBe('unknown')
  51. })
  52. it('preserves startup options and ZDOTDIR and supports noclobber status updates', async () => {
  53. const h = await shell('/bin/zsh', 'setopt noclobber\nprint RC-LOADED', 'print ENV-LOADED')
  54. expect(h.output()).toContain('ENV-LOADED')
  55. expect(h.output()).toContain('RC-LOADED')
  56. await expect.poll(h.activity).toBe('idle')
  57. await h.handle.write('print -r -- "DIRECTORY:$ZDOTDIR"\r')
  58. await expect.poll(() => h.output()).toContain(`DIRECTORY:${h.home}`)
  59. await expect.poll(h.activity).toBe('idle')
  60. expect(h.output()).not.toContain('file exists')
  61. })
  62. it('protects a stopped job even after the shell returns to the prompt', async () => {
  63. const h = await shell('/bin/zsh')
  64. await expect.poll(h.activity).toBe('idle')
  65. await h.handle.write('sleep 600\r')
  66. await expect.poll(h.activity).toBe('busy')
  67. await h.handle.write('\x1a')
  68. await expect.poll(() => h.output()).toContain('suspended')
  69. expect(await h.activity()).toBe('busy')
  70. await h.handle.write('kill -KILL %1\r')
  71. await expect.poll(h.activity).toBe('idle')
  72. })
  73. it('refuses idle evidence when custom signal traps may run without terminal input', async () => {
  74. const h = await shell('/bin/zsh', "trap ':' USR1")
  75. expect(await h.activity()).toBe('unknown')
  76. await h.handle.write('trap - USR1\r')
  77. await expect.poll(h.activity).toBe('idle')
  78. })
  79. it('leaves disabled startup files disabled and restores ZDOTDIR immediately', async () => {
  80. const h = await shell('/bin/zsh', 'print UNEXPECTED-RC', "unsetopt rcs\nPROMPT='READY> '")
  81. expect(h.output()).not.toContain('UNEXPECTED-RC')
  82. await h.handle.write('print -r -- "DIRECTORY:$ZDOTDIR"\r')
  83. await expect.poll(() => h.output()).toContain(`DIRECTORY:${h.home}`)
  84. })
  85. })
  86. describe.skipIf(process.platform === 'win32' || !existsSync('/bin/bash'))('Bash terminal activity', () => {
  87. it('preserves scalar prompt hooks and uses unknown on shells without PS0', async () => {
  88. const h = await shell('bash', "PROMPT_COMMAND='printf HOOK; # user comment'\nset -C")
  89. expect(h.output()).toContain('HOOK')
  90. await h.handle.write('printf "VERSION:%s\\n" "$BASH_VERSION"\r')
  91. await expect.poll(() => h.output()).toContain('VERSION:')
  92. await expect.poll(() => /VERSION:\d+\.\d+/u.test(h.output())).toBe(true)
  93. const version = /VERSION:(\d+)\.(\d+)/u.exec(h.output())
  94. const supported = Number(version?.[1]) > 4 || Number(version?.[1]) === 4 && Number(version?.[2]) >= 4
  95. if (!supported) { expect(await h.activity()).toBe('unknown'); return }
  96. await expect.poll(h.activity).toBe('idle')
  97. for (const command of ['sleep 600', 'while :; do :; done', 'read answer']) {
  98. await h.handle.write(`${command}\r`)
  99. await expect.poll(h.activity, { message: command }).toBe('busy')
  100. await h.handle.write('\x03')
  101. await expect.poll(h.activity).toBe('idle')
  102. }
  103. expect(h.output()).not.toContain('syntax error')
  104. expect(h.output()).not.toContain('file exists')
  105. })
  106. })