| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736 |
- /** Reloaded tab identities and nonblocking close requests use independent lifetimes. */
- import { setImmediate } from 'node:timers/promises'
- import { afterEach, expect, it, vi } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
- import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
- import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
- import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
- import type { SessionId } from '@deepseek-ai/dsh-session/types'
- import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts'
- import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
- import { TerminalCloseRequests } from '../src/client/close-requests.ts'
- import { TerminalBindings } from '../src/client/bindings.ts'
- import * as TerminalClient from '../src/client/index.ts'
- const sessionId = 'session' as SessionId
- const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null }
- const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 }
- const success = <T>(value: T): RemoteResult<T> => ({ ok: true, value })
- const failure = (message: string): RemoteResult<never> => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) })
- const cleanups: (() => void | Promise<void>)[] = []
- afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
- function storage() {
- const data = new Map<string, string>()
- vi.stubGlobal('localStorage', { get length() { return data.size }, key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value) }, removeItem: (key: string) => { data.delete(key) } })
- return data
- }
- function fixture() {
- const remote: TerminalRemote = {
- retain: vi.fn<TerminalRemote['retain']>(async function* (_session, _id, signal) {
- yield { type: 'retained' }
- await new Promise<void>((resolve) => {
- if (signal?.aborted) resolve()
- else signal?.addEventListener('abort', () => { resolve() }, { once: true })
- })
- }),
- shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
- environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
- create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
- close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
- write: vi.fn<TerminalRemote['write']>(async () => success(undefined)), resize: vi.fn<TerminalRemote['resize']>(async () => success(undefined)),
- follow: vi.fn<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
- yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } }
- await new Promise<void>((resolve) => {
- if (signal?.aborted) resolve()
- else signal?.addEventListener('abort', () => { resolve() }, { once: true })
- })
- }),
- }
- const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
- function view() {
- const model = new TerminalView(sessionId, remote, gateway, info.id)
- cleanups.push(() => model.dispose())
- return model
- }
- async function service() {
- const ctx = new Context()
- ctx.provide('remote', { ...gateway, terminal: remote } as never)
- ctx.provide('remote.terminal', remote)
- const fiber = ctx.plugin(TerminalClient)
- cleanups.push(async () => { await ctx.fiber.dispose() })
- await fiber
- return { service: ctx.webTerminals, dispose: () => fiber.dispose() }
- }
- return { remote, view, service }
- }
- it('starts automatically and deduplicates overlapping mounts and refreshes', async () => {
- const h = fixture()
- const model = h.view()
- const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
- vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
- model.mount()
- const loading = model.refresh()
- expect(model.refresh()).toBe(loading)
- await expect.poll(() => h.remote.create).toHaveBeenCalledOnce()
- const remounting = model.refresh()
- expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 })
- creation.resolve(success(info))
- await loading
- await remounting
- await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
- expect(h.remote.create).toHaveBeenCalledOnce()
- })
- it('recovers a listed process without creating another, including its title and screen', async () => {
- const h = fixture()
- vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }]))
- const model = h.view()
- model.mount()
- await model.refresh()
- expect(h.remote.create).not.toHaveBeenCalled()
- await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
- expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal))
- })
- it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => {
- const h = fixture()
- const { service } = await h.service()
- vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
- expect(await service.recover(sessionId)).toEqual([info])
- const model = service.view(sessionId, 'recovered', 'recovered', info.id)
- await model.refresh()
- expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false })
- expect(model.state.getSnapshot().issue).toBe('missingTerminal')
- expect(h.remote.create).not.toHaveBeenCalled()
- model.mount()
- await model.refresh()
- expect(h.remote.create).not.toHaveBeenCalled()
- })
- it('retries lost create acknowledgements with the saved id and closes after a refused allocation', async () => {
- const h = fixture()
- const model = h.view()
- vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost'))
- await model.refresh()
- expect(model.state.getSnapshot().error).toBe('response lost')
- await model.refresh()
- expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id])
- await model.close()
- expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
- })
- it('waits for an in-flight creation while close detaches immediately and prevents a late connection', async () => {
- const h = fixture()
- const model = h.view()
- const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
- vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
- const unmount = model.mount()
- const starting = model.refresh()
- await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
- const closing = model.close()
- expect(model.close()).toBe(closing)
- unmount()
- expect(h.remote.close).not.toHaveBeenCalled()
- creation.resolve(failure('cancelled allocation'))
- await starting
- await closing
- expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
- expect(h.remote.follow).not.toHaveBeenCalled()
- })
- it('does not allocate after close or disposal overtakes discovery', async () => {
- for (const action of ['close', 'dispose'] as const) {
- const h = fixture()
- const model = h.view()
- const discovery = Promise.withResolvers<RemoteResult<TerminalEnvironment>>()
- vi.mocked(h.remote.environment).mockReturnValueOnce(discovery.promise)
- const loading = model.refresh()
- await model[action]()
- discovery.resolve(success(environment))
- await loading
- await model.refresh()
- expect(h.remote.create).not.toHaveBeenCalled()
- }
- })
- it('updates a restored inactive terminal title and ignores late results after disposal', async () => {
- const h = fixture()
- const model = h.view()
- await model.rename(' Build ')
- expect(h.remote.rename).toHaveBeenCalledWith(sessionId, info.id, ' Build ')
- expect(model.state.getSnapshot().title).toBe('Build')
- const rename = Promise.withResolvers<RemoteResult<void>>()
- vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise)
- const pending = model.rename('Late')
- await model.dispose()
- rename.resolve(success(undefined))
- await pending
- expect(model.state.getSnapshot().title).toBe('Build')
- })
- it('removes a view synchronously, keeps cleanup retryable, and never deletes a replacement view', async () => {
- storage()
- const h = fixture()
- const { service } = await h.service()
- const first = service.view(sessionId, 'tab', 'tab')
- first.mount()
- await first.refresh()
- const closing = Promise.withResolvers<RemoteResult<void>>()
- vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise)
- service.close(sessionId, 'tab', 'tab')
- const replacement = service.view(sessionId, 'tab', 'tab')
- expect(replacement).not.toBe(first)
- expect(service.closeFailures.getSnapshot()).toEqual([])
- closing.resolve(failure('termination refused'))
- await expect.poll(() => service.closeFailures.getSnapshot().length).toBe(1)
- const failed = service.closeFailures.getSnapshot()[0]!
- service.retryClose(failed.id)
- service.retryClose(failed.id)
- await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([])
- expect(service.view(sessionId, 'tab', 'tab')).toBe(replacement)
- await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
- })
- it('closes an inactive restored tab by tab identity and retries saved close requests after reload', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- first.service.close(sessionId, 'inactive', 'inactive', 'inactive' as WebTerminalId)
- await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
- expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive')
- await first.dispose()
- const pending = new TerminalCloseRequests()
- pending.save({ sessionId, id: 'second' as WebTerminalId, title: 'Second' })
- await h.service()
- await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(2)
- expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'second')
- await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
- })
- it('keeps concurrent windows close requests independent and removes only the settled request', () => {
- storage()
- const a = new TerminalCloseRequests()
- const b = new TerminalCloseRequests()
- a.save({ sessionId, id: 'a' as WebTerminalId, title: 'A' })
- b.save({ sessionId, id: 'b' as WebTerminalId, title: 'B' })
- expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['a', 'b'])
- a.remove('a' as WebTerminalId)
- expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['b'])
- })
- it('recovers Host terminals by Session while excluding held, pending-close, and already-closed identities', async () => {
- storage()
- const h = fixture()
- const { service } = await h.service()
- const held = service.view(sessionId, 'held', 'held')
- await held.refresh()
- const closing = service.view(sessionId, 'closing', 'closing')
- await closing.refresh()
- const done = service.view(sessionId, 'closed', 'closed')
- await done.refresh()
- const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId }
- const pending = Promise.withResolvers<RemoteResult<void>>()
- vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
- service.close(sessionId, 'closing', 'closing')
- service.close(sessionId, 'closed', 'closed')
- await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1)
- vi.mocked(h.remote.list).mockResolvedValue(success([
- { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld,
- ]))
- expect(await service.recover(sessionId)).toEqual([unheld])
- expect(h.remote.list).toHaveBeenLastCalledWith(sessionId)
- const otherSession = 'other-session' as SessionId
- vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
- expect(await service.recover(otherSession)).toEqual([info])
- pending.resolve(success(undefined))
- await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
- expect(await service.recover(sessionId)).toEqual([unheld])
- })
- it('queries current held views after a slow recovery response and reports discovery errors', async () => {
- const h = fixture()
- const { service } = await h.service()
- const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
- vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
- const recovering = service.recover(sessionId)
- vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
- const model = service.view(sessionId, 'recovered', 'recovered', info.id)
- await model.refresh()
- listed.resolve(success([info]))
- expect(await recovering).toEqual([])
- expect(service.view(sessionId, 'recovered', 'recovered', info.id)).toBe(model)
- expect(model.id).toBe(info.id)
- vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable'))
- await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable')
- })
- it('ignores an unknown tab and retries only saved close requests', async () => {
- storage()
- const h = fixture()
- const { service } = await h.service()
- service.close(sessionId, 'unknown', 'unknown')
- service.retryClose('unknown' as WebTerminalId)
- expect(h.remote.close).not.toHaveBeenCalled()
- expect(new TerminalCloseRequests().pending()).toEqual([])
- })
- it('retains an inactive close failure with its tab title until retry succeeds', async () => {
- const data = storage()
- const h = fixture()
- const { service } = await h.service()
- vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup'))
- service.close(sessionId, 'Build', 'Build', info.id)
- expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true)
- await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }])
- vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
- expect(await service.recover(sessionId)).toEqual([])
- const pending = Promise.withResolvers<RemoteResult<void>>()
- vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
- service.retryClose(info.id)
- service.retryClose(info.id)
- expect(service.closeFailures.getSnapshot()).toEqual([])
- expect(h.remote.close).toHaveBeenCalledTimes(2)
- pending.resolve(success(undefined))
- await expect.poll(() => data.size).toBe(0)
- })
- it('preserves a close failure from a non-Error rejection', async () => {
- storage()
- const h = fixture()
- const { service } = await h.service()
- vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed')
- service.close(sessionId, 'Build', 'Build', info.id)
- await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }])
- })
- it('waits for pending cleanup during service disposal without publishing a late failure', async () => {
- storage()
- const h = fixture()
- const { service, dispose } = await h.service()
- const pending = Promise.withResolvers<RemoteResult<void>>()
- vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
- service.close(sessionId, 'Build', 'Build', info.id)
- let finished = false
- const disposing = dispose().then(() => { finished = true })
- expect(finished).toBe(false)
- pending.resolve(failure('transport is stopping'))
- await disposing
- expect(service.closeFailures.getSnapshot()).toEqual([])
- expect(new TerminalCloseRequests().pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }])
- service.retryClose(info.id)
- expect(h.remote.close).toHaveBeenCalledOnce()
- })
- it('persists occurrence identities before allocation and removes them on close without saving process output', async () => {
- const data = storage()
- const h = fixture()
- const { service } = await h.service()
- const model = service.view(sessionId, 'new-tab', 'new-tab')
- const key = 'dsh.terminal.binding.v1.' + JSON.stringify([sessionId, 'new-tab'])
- expect(JSON.parse(data.get(key)!)).toBe(model.id)
- await model.refresh()
- expect(model.id).toMatch(/^[0-9a-f-]{36}$/)
- expect([...data.keys()]).toEqual([key, 'dsh.terminal.shell'])
- const pending = Promise.withResolvers<RemoteResult<void>>()
- vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
- service.close(sessionId, 'new-tab', 'new-tab')
- const request = { sessionId, id: model.id, title: info.title }
- expect(data.has(key)).toBe(false)
- expect(data.get(`dsh.terminal.close.v1.${model.id}`)).toBe(JSON.stringify(request))
- pending.resolve(success(undefined))
- await expect.poll(() => [...data.keys()]).toEqual(['dsh.terminal.shell'])
- })
- it('restores the same terminal in the same occurrence after reload without opening a recovery duplicate', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- const original = first.service.view(sessionId, 'tab', 'tab')
- await original.refresh()
- await first.dispose()
- vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, id: original.id }]))
- const second = await h.service()
- const restored = second.service.view(sessionId, 'tab', 'tab')
- expect(await second.service.recover(sessionId)).toEqual([])
- restored.mount()
- await restored.refresh()
- expect(restored.id).toBe(original.id)
- expect(h.remote.create).toHaveBeenCalledOnce()
- await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
- })
- it('offers retained processes when their saved occurrence is absent from the restored layout', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- const original = first.service.view(sessionId, 'lost-tab', 'lost-tab')
- await original.refresh()
- await first.dispose()
- const retained = { ...info, id: original.id }
- vi.mocked(h.remote.list).mockResolvedValue(success([retained]))
- const second = await h.service()
- expect(await second.service.recover(sessionId)).toEqual([retained])
- })
- it('keeps identical occurrence keys in different Sessions independent across reload', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- const a = first.service.view(sessionId, 'tab', 'tab')
- const other = 'other' as SessionId
- const b = first.service.view(other, 'tab', 'tab')
- await Promise.all([a.refresh(), b.refresh()])
- expect(a.id).not.toBe(b.id)
- await first.dispose()
- const second = await h.service()
- expect(second.service.view(sessionId, 'tab', 'tab').id).toBe(a.id)
- expect(second.service.view(other, 'tab', 'tab').id).toBe(b.id)
- })
- it('closes a saved inactive occurrence without mounting a view after reload', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- const original = first.service.view(sessionId, 'tab', 'tab')
- await original.refresh()
- await first.dispose()
- const second = await h.service()
- second.service.close(sessionId, 'tab', 'tab')
- await expect.poll(() => h.remote.close).toHaveBeenCalledWith(sessionId, original.id)
- expect(h.remote.create).toHaveBeenCalledOnce()
- })
- it('reports a missing saved terminal after reload and never starts a replacement shell', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- const original = first.service.view(sessionId, 'tab', 'tab')
- await original.refresh()
- await first.dispose()
- const second = await h.service()
- const restored = second.service.view(sessionId, 'tab', 'tab')
- await restored.refresh()
- expect(restored.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'missingTerminal' })
- await restored.refresh()
- expect(h.remote.create).toHaveBeenCalledOnce()
- })
- it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => {
- const data = storage()
- data.set('dsh.terminal.close.v1.terminal', raw)
- const error = vi.spyOn(console, 'error').mockImplementation(() => {})
- expect(new TerminalCloseRequests().pending()).toEqual([])
- expect(error).toHaveBeenCalledOnce()
- })
- it('skips unrelated storage and cleanup keys removed during enumeration', () => {
- const getItem = vi.fn(() => null)
- vi.stubGlobal('localStorage', {
- length: 3,
- key: (index: number) => ['unrelated', 'dsh.terminal.close.v1.gone', null][index],
- getItem,
- })
- expect(new TerminalCloseRequests().pending()).toEqual([])
- expect(getItem).toHaveBeenCalledExactlyOnceWith('dsh.terminal.close.v1.gone')
- })
- it('keeps close requests usable without browser storage', () => {
- vi.stubGlobal('localStorage', undefined)
- const requests = new TerminalCloseRequests()
- requests.save({ sessionId, id: info.id, title: 'Build' })
- expect(requests.pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }])
- requests.remove(info.id)
- expect(requests.pending()).toEqual([])
- })
- it('keeps cleanup usable in memory when storage access itself is denied', () => {
- const error = vi.spyOn(console, 'error').mockImplementation(() => {})
- vi.stubGlobal('localStorage', undefined)
- Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('storage denied') } })
- const requests = new TerminalCloseRequests()
- requests.save({ sessionId, id: info.id, title: 'Build' })
- expect(requests.pending()).toHaveLength(1)
- requests.remove(info.id)
- expect(requests.pending()).toEqual([])
- expect(error).toHaveBeenCalledTimes(3)
- })
- it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => {
- const data = storage()
- const h = fixture()
- const requests = new TerminalCloseRequests()
- if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' })
- vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) })
- const { service, dispose } = await h.service()
- if (source === 'view') {
- const view = service.view(sessionId, 'tab', 'tab')
- await view.refresh()
- service.close(sessionId, 'tab', 'tab')
- }
- await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
- await dispose()
- expect([...data.entries()]).toEqual(source === 'view'
- ? [['dsh.terminal.shell', info.shell.path]] : [])
- expect(service.closeFailures.getSnapshot()).toEqual([])
- expect(new TerminalCloseRequests().pending()).toEqual([])
- await h.service()
- expect(h.remote.close).toHaveBeenCalledOnce()
- })
- it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => {
- const h = fixture()
- const { service, dispose } = await h.service()
- const started = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
- const release = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
- const finished = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
- cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) })
- let index = 0
- vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
- const current = index++
- try {
- yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } }
- await new Promise<void>((resolve) => {
- if (signal?.aborted) resolve()
- else signal?.addEventListener('abort', () => { resolve() }, { once: true })
- })
- } finally {
- started[current]!.resolve(undefined)
- await release[current]!.promise
- finished[current]!.resolve(undefined)
- }
- })
- const model = service.view(sessionId, 'tab', 'tab')
- model.mount()
- await model.refresh()
- await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
- model.connect()
- await started[0]!.promise
- await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
- let disposed = false
- const disposing = dispose().then(() => { disposed = true })
- await started[1]!.promise
- release[1]!.resolve(undefined)
- await finished[1]!.promise
- // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending.
- await setImmediate()
- expect(disposed).toBe(false)
- release[0]!.resolve(undefined)
- await disposing
- expect(disposed).toBe(true)
- expect(h.remote.close).not.toHaveBeenCalled()
- })
- it('discovers menu choices without creating a process and remembers a choice before opening its tab', async () => {
- const data = storage()
- const h = fixture()
- const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] }
- vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate]))
- const { service } = await h.service()
- expect(await service.launchShells(sessionId, new AbortController().signal)).toEqual({
- shells: [info.shell, alternate], selectedShell: info.shell.path,
- })
- service.selectShell(alternate.path)
- expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
- expect(h.remote.create).not.toHaveBeenCalled()
- expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBe(alternate.path)
- const model = service.view(sessionId, 'chosen', 'chosen', undefined, alternate.path)
- await model.refresh()
- expect(h.remote.create).toHaveBeenLastCalledWith(
- sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
- )
- expect(h.remote.shells).toHaveBeenCalledTimes(2)
- await h.view().refresh()
- expect(h.remote.create).toHaveBeenLastCalledWith(
- sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
- )
- vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell]))
- await h.view().refresh()
- expect(h.remote.create).toHaveBeenLastCalledWith(
- sessionId, expect.objectContaining({ shellPath: info.shell.path }), expect.any(AbortSignal),
- )
- vi.mocked(h.remote.shells).mockResolvedValueOnce(failure('host offline'))
- await expect(service.launchShells(sessionId, new AbortController().signal)).rejects.toThrow('host offline')
- vi.mocked(h.remote.shells).mockResolvedValue(success([]))
- expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBeUndefined()
- await h.view().refresh()
- expect(vi.mocked(h.remote.create).mock.calls.at(-1)?.[1]).not.toHaveProperty('shellPath')
- })
- it('retains the last selection across a failed automatic launch', async () => {
- const data = storage()
- const h = fixture()
- vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared'))
- const model = h.view()
- await model.refresh()
- expect(data.get('dsh.terminal.shell')).toBe(info.shell.path)
- expect(model.state.getSnapshot().error).toBe('shell disappeared')
- await model.refresh()
- expect(h.remote.create).toHaveBeenCalledTimes(2)
- await model.close()
- await model.refresh()
- expect(h.remote.create).toHaveBeenCalledTimes(2)
- })
- it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => {
- vi.stubGlobal('localStorage', undefined)
- Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } })
- const h = fixture()
- const model = h.view()
- await model.refresh()
- expect(model.state.getSnapshot().info).toBeDefined()
- const delayed = h.view()
- const shells = Promise.withResolvers<Awaited<ReturnType<TerminalRemote['shells']>>>()
- vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise)
- const loading = delayed.refresh()
- await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2)
- await delayed.close()
- shells.resolve(success([info.shell]))
- await loading
- expect(delayed.state.getSnapshot().phase).toBe('closed')
- expect(h.remote.create).toHaveBeenCalledOnce()
- })
- it('retains only open saved occurrences across inactive Sessions and deduplicates their Host identities', async () => {
- storage()
- const otherSession = 'dormant-session' as SessionId
- const otherId = 'other-terminal' as WebTerminalId
- const bindings = new TerminalBindings()
- bindings.set(sessionId, 'a', info.id)
- bindings.set(sessionId, 'duplicate', info.id)
- bindings.set(sessionId, 'stale', 'orphan' as WebTerminalId)
- bindings.set(otherSession, 'b', otherId)
- const h = fixture()
- const { service } = await h.service()
- service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }, { sessionId, tabId: 'duplicate', contentId: 'duplicate' }, { sessionId: otherSession, tabId: 'b', contentId: 'b' }])
- await expect.poll(() => h.remote.retain).toHaveBeenCalledTimes(2)
- expect(h.remote.environment).not.toHaveBeenCalled()
- expect(h.remote.create).not.toHaveBeenCalled()
- expect(h.remote.list).not.toHaveBeenCalled()
- expect(h.remote.follow).not.toHaveBeenCalled()
- const calls = vi.mocked(h.remote.retain).mock.calls
- service.retainTabs([{ sessionId: otherSession, tabId: 'b', contentId: 'b' }])
- await expect.poll(() => calls.find(call => call[0] === sessionId)?.[2]?.aborted).toBe(true)
- expect(calls.find(call => call[0] === otherSession)?.[2]?.aborted).toBe(false)
- service.retainTabs([])
- await expect.poll(() => calls.every(call => call[2]?.aborted)).toBe(true)
- })
- it('waits for the window hold acknowledgement before restoring an output attachment', async () => {
- storage()
- new TerminalBindings().set(sessionId, 'restored', info.id)
- const h = fixture()
- vi.mocked(h.remote.list).mockResolvedValue(success([info]))
- const acknowledge = Promise.withResolvers<undefined>()
- vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) {
- await acknowledge.promise
- yield { type: 'retained' }
- await new Promise<void>((resolve) => {
- if (signal?.aborted) resolve()
- else signal?.addEventListener('abort', () => { resolve() }, { once: true })
- })
- })
- const { service } = await h.service()
- service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
- const model = service.view(sessionId, 'restored', 'restored')
- model.mount()
- await model.refresh()
- expect(h.remote.follow).not.toHaveBeenCalled()
- acknowledge.resolve(undefined)
- await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
- expect(h.remote.retain).toHaveBeenCalledOnce()
- expect(h.remote.create).not.toHaveBeenCalled()
- })
- it('shows a missing terminal when retention loses the race to Host cleanup, without allocating a replacement', async () => {
- storage()
- new TerminalBindings().set(sessionId, 'restored', info.id)
- const h = fixture()
- vi.mocked(h.remote.list).mockResolvedValue(success([info]))
- vi.mocked(h.remote.retain).mockImplementation(() => { throw new RemoteError('terminal/unavailable', 'gone', {}) })
- const { service } = await h.service()
- service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
- const model = service.view(sessionId, 'restored', 'restored')
- model.mount()
- await expect.poll(() => model.state.getSnapshot().issue).toBe('missingTerminal')
- expect(h.remote.follow).not.toHaveBeenCalled()
- expect(h.remote.create).not.toHaveBeenCalled()
- })
- it('ignores late inventory after disposal and excludes missing bindings and saved close requests', async () => {
- storage()
- new TerminalBindings().set(sessionId, 'closing', info.id)
- new TerminalCloseRequests().save({ sessionId, id: info.id, title: 'Closing' })
- const h = fixture()
- const { service, dispose } = await h.service()
- service.retainTabs([{ sessionId, tabId: 'missing', contentId: 'missing' }, { sessionId, tabId: 'closing', contentId: 'closing' }])
- expect(h.remote.retain).not.toHaveBeenCalled()
- await dispose()
- service.retainTabs([{ sessionId, tabId: 'closing', contentId: 'closing' }])
- expect(h.remote.retain).not.toHaveBeenCalled()
- })
- it('keeps other holds usable when releasing one transport fails', async () => {
- storage()
- new TerminalBindings().set(sessionId, 'a', info.id)
- const h = fixture()
- const { service } = await h.service()
- service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }])
- await expect.poll(() => h.remote.retain).toHaveBeenCalledOnce()
- const { TerminalWindowHold } = await import('../src/client/retention.ts')
- // oxlint-disable-next-line typescript/unbound-method -- Preserve the real disposer while injecting one failure after it settles.
- const original = TerminalWindowHold.prototype.dispose
- const failure = vi.spyOn(TerminalWindowHold.prototype, 'dispose')
- failure.mockImplementationOnce(async function (this: InstanceType<typeof TerminalWindowHold>) {
- await original.call(this)
- throw new Error('transport close failed')
- })
- service.retainTabs([])
- await expect.poll(() => failure).toHaveBeenCalledOnce()
- await setImmediate()
- failure.mockRestore()
- service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }])
- await expect.poll(() => h.remote.retain).toHaveBeenCalledTimes(2)
- })
- it('ignores a delayed retention failure after the restored view was disposed', async () => {
- const h = fixture()
- vi.mocked(h.remote.list).mockResolvedValue(success([info]))
- const retained = Promise.withResolvers<undefined>()
- const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
- const model = new TerminalView(sessionId, h.remote, gateway, info.id, false, undefined, () => retained.promise)
- cleanups.push(() => model.dispose())
- await model.refresh()
- await model.dispose()
- const snapshot = model.state.getSnapshot()
- retained.reject(new Error('late hold rejection'))
- await setImmediate()
- expect(model.state.getSnapshot()).toBe(snapshot)
- })
- it('keeps new terminals with colliding layout-local tab ids independent across shared-storage windows', async () => {
- storage()
- const h = fixture()
- const first = await h.service()
- const second = await h.service()
- const a = first.service.view(sessionId, 'tab2', 'sidebar://terminal/a')
- await a.refresh()
- const b = second.service.view(sessionId, 'tab2', 'sidebar://terminal/b')
- await b.refresh()
- expect(a.id).not.toBe(b.id)
- expect(h.remote.create).toHaveBeenCalledTimes(2)
- first.service.close(sessionId, 'tab2', 'sidebar://terminal/a')
- await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
- expect(h.remote.close).toHaveBeenCalledWith(sessionId, a.id)
- expect(h.remote.close).not.toHaveBeenCalledWith(sessionId, b.id)
- await second.dispose()
- vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, id: b.id }]))
- const reloaded = await h.service()
- const restored = reloaded.service.view(sessionId, 'tab2', 'sidebar://terminal/b')
- await restored.refresh()
- expect(restored.id).toBe(b.id)
- expect(h.remote.create).toHaveBeenCalledTimes(2)
- })
|