terminal.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. /** Terminal screen continuity and process ownership under real output scheduling. */
  2. import { PassThrough } from 'node:stream'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
  5. import { BrowserTerminal } from '../src/terminal.ts'
  6. import { TerminalFollower } from '../src/stream.ts'
  7. import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts'
  8. const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }
  9. const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId
  10. const cleanups: (() => Promise<void>)[] = []
  11. afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) })
  12. function fixture() {
  13. const output = new PassThrough()
  14. const outcome = Promise.withResolvers<SubprocessOutcome>()
  15. const handle = {
  16. pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
  17. inspectForeground: async () => undefined, signalForeground: async () => 123,
  18. terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }),
  19. }
  20. const checked: SubprocessTerminalHandle = handle
  21. const terminal = new BrowserTerminal(checked, info, 100, 100_000)
  22. cleanups.push(() => terminal.close())
  23. return { terminal, output, outcome, handle }
  24. }
  25. async function attach(terminal: BrowserTerminal, id = 'first') {
  26. const controller = new AbortController()
  27. const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]()
  28. const baseline = await readFrame(iterator)
  29. cleanups.push(async () => { controller.abort(); await iterator.return?.() })
  30. return { controller, iterator, baseline }
  31. }
  32. describe('BrowserTerminal', () => {
  33. it('restores the screen after detach without re-executing the shell or replaying duplicate output', async () => {
  34. const { terminal, output, handle } = fixture()
  35. const first = await attach(terminal)
  36. expect(first.baseline).toMatchObject({ type: 'snapshot', sequence: 0 })
  37. output.write(Buffer.from('hello\r\n'))
  38. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', sequence: 1, data: 'hello\r\n' })
  39. first.controller.abort()
  40. await first.iterator.return?.()
  41. expect(handle.terminate).not.toHaveBeenCalled()
  42. output.write(Buffer.from('world'))
  43. await expect.poll(() => terminal.info.state).toBe('running')
  44. const second = await attach(terminal, 'second')
  45. // The baseline and its output queue share the same ordered screen write queue.
  46. const frames = [second.baseline]
  47. if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator))
  48. expect(JSON.stringify(frames)).toContain('world')
  49. expect(JSON.stringify(frames)).toContain('hello')
  50. expect(handle.terminate).not.toHaveBeenCalled()
  51. })
  52. it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => {
  53. const { terminal, output, handle } = fixture()
  54. const first = await attach(terminal)
  55. const encoded = Buffer.from('终端')
  56. output.write(encoded.subarray(0, 2))
  57. await expect.poll(() => output.readableLength).toBe(0)
  58. output.write(encoded.subarray(2))
  59. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' })
  60. const second = await attach(terminal, 'second')
  61. await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } })
  62. await terminal.write(attachment('second'), '\t')
  63. expect(handle.write).toHaveBeenCalledWith('\t')
  64. await terminal.resize(attachment('second'), 100, 30)
  65. expect(handle.resize).toHaveBeenCalledWith(100, 30)
  66. expect(terminal.info).toMatchObject({ cols: 100, rows: 30 })
  67. second.controller.abort()
  68. await second.iterator.return?.()
  69. expect(terminal.info.controllerId).toBeUndefined()
  70. })
  71. it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => {
  72. const { terminal, output, outcome, handle } = fixture()
  73. const first = await attach(terminal)
  74. output.end('done')
  75. outcome.resolve({ exitCode: 7, signal: null })
  76. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' })
  77. expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } })
  78. expect(handle.terminate).not.toHaveBeenCalled()
  79. const second = await attach(terminal, 'second')
  80. expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } })
  81. await terminal.close()
  82. expect(handle.terminate).toHaveBeenCalledOnce()
  83. })
  84. it('drains final output and exit state before closing followers', async () => {
  85. const { terminal, handle, output, outcome } = fixture()
  86. const first = await attach(terminal)
  87. vi.mocked(handle.terminate).mockImplementationOnce(async () => {
  88. output.end('FINAL OUTPUT\r\n')
  89. outcome.resolve({ exitCode: 0, signal: null })
  90. })
  91. await terminal.close()
  92. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT\r\n' })
  93. expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
  94. expect((await first.iterator.next()).done).toBe(true)
  95. })
  96. it('rejects input before attachment, during close and after process exit', async () => {
  97. const { terminal, handle, output, outcome } = fixture()
  98. await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } })
  99. const first = await attach(terminal)
  100. output.end()
  101. outcome.resolve({ exitCode: 0, signal: null })
  102. expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
  103. await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
  104. await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
  105. const closing = terminal.close()
  106. await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
  107. await closing
  108. expect(handle.write).not.toHaveBeenCalled()
  109. expect(handle.resize).not.toHaveBeenCalled()
  110. })
  111. it('preserves the input controller when an older follower detaches', async () => {
  112. const { terminal, handle } = fixture()
  113. const first = await attach(terminal)
  114. const second = await attach(terminal, 'second')
  115. first.controller.abort()
  116. await first.iterator.return?.()
  117. expect(terminal.info.controllerId).toBe(attachment('second'))
  118. terminal.rename('build output')
  119. expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } })
  120. await terminal.write(attachment('second'), 'pwd\r')
  121. expect(handle.write).toHaveBeenCalledWith('pwd\r')
  122. })
  123. it('does not grant input to attachments cancelled before their snapshot is ready', async () => {
  124. const { terminal, handle } = fixture()
  125. const aborted = new AbortController()
  126. aborted.abort(new Error('already detached'))
  127. await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached')
  128. await attach(terminal)
  129. const writing = Promise.withResolvers<undefined>()
  130. const written = Promise.withResolvers<undefined>()
  131. handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise })
  132. const pendingWrite = terminal.write(attachment('first'), 'pwd\r')
  133. try {
  134. await writing.promise
  135. const abort = new AbortController()
  136. const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next()
  137. const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting')
  138. abort.abort(new Error('detached while waiting'))
  139. written.resolve(undefined)
  140. await pendingWrite
  141. await rejected
  142. expect(terminal.info.controllerId).toBe(attachment('first'))
  143. } finally { written.resolve(undefined) }
  144. })
  145. it('continues accepting operations after a provider write or resize fails', async () => {
  146. const { terminal, handle } = fixture()
  147. await attach(terminal)
  148. handle.write.mockRejectedValueOnce(new Error('input transport failed'))
  149. handle.resize.mockRejectedValueOnce(new Error('resize transport failed'))
  150. await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed')
  151. await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed')
  152. expect(terminal.info).toMatchObject({ cols: 80, rows: 24 })
  153. await terminal.write(attachment('first'), 'accepted')
  154. await terminal.resize(attachment('first'), 90, 25)
  155. expect(handle.write).toHaveBeenLastCalledWith('accepted')
  156. expect(terminal.info).toMatchObject({ cols: 90, rows: 25 })
  157. })
  158. it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => {
  159. const { terminal, output, outcome } = fixture()
  160. const first = await attach(terminal)
  161. output.end('last output')
  162. outcome.reject(failure)
  163. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' })
  164. expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } })
  165. const second = await attach(terminal, 'second')
  166. expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } })
  167. })
  168. it('publishes output-stream failure even when the process wait succeeds', async () => {
  169. const { terminal, output, outcome } = fixture()
  170. const first = await attach(terminal)
  171. output.destroy(new Error('output transport failed'))
  172. outcome.resolve({ exitCode: 0, signal: null })
  173. expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: 'output transport failed' } })
  174. })
  175. it('flushes incomplete UTF-8 at EOF before publishing the process exit', async () => {
  176. const { terminal, output, outcome } = fixture()
  177. const first = await attach(terminal)
  178. output.end(Buffer.from([0xe7, 0xbb]))
  179. outcome.resolve({ exitCode: 0, signal: null })
  180. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '�' })
  181. expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
  182. })
  183. it('preserves a leading UTF-8 BOM in the terminal output stream', async () => {
  184. const { terminal, output } = fixture()
  185. const first = await attach(terminal)
  186. output.write(Buffer.from('\uFEFF终端'))
  187. expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '\uFEFF终端' })
  188. })
  189. it('shares concurrent close attempts and permits retry after termination fails', async () => {
  190. const { terminal, handle } = fixture()
  191. await attach(terminal)
  192. handle.terminate.mockRejectedValueOnce(new Error('process range remains alive'))
  193. const first = terminal.close()
  194. expect(terminal.close()).toBe(first)
  195. await expect(first).rejects.toThrow('remains alive')
  196. await terminal.write(attachment('first'), 'retry cleanup next')
  197. await terminal.close()
  198. expect(handle.terminate).toHaveBeenCalledTimes(2)
  199. })
  200. it('reports overflow rather than silently discarding output', async () => {
  201. const follower = new TerminalFollower(64)
  202. follower.push({ type: 'output', sequence: 1, data: 'x'.repeat(128) })
  203. await expect(follower.read(new AbortController().signal)[Symbol.asyncIterator]().next()).rejects.toThrow('buffer')
  204. })
  205. })
  206. async function readFrame(iterator: AsyncIterator<TerminalFrame>): Promise<TerminalFrame> {
  207. const result = await iterator.next()
  208. if (result.done === true) throw new Error('Terminal stream ended before its expected frame')
  209. return result.value
  210. }