/** Browser view ownership across slow RPCs, remounts and transport generations. */ import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import { RemoteMock, streamMethod, type StreamHandle } from '@deepseek-ai/dsh-remote-mock' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TerminalView, type TerminalRemote } from '../src/client/model.ts' import { TerminalInventory } from '../src/client/inventory.ts' import type { TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' const sessionId = 'session' as SessionId const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxBufferedBytes: 100_000, maxCols: 200, maxRows: 100, scrollback: 100, } const success = (value: T): RemoteResult => ({ ok: true, value }) const cleanups: (() => void | Promise)[] = [] afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() }) function untilAborted(signal?: AbortSignal): Promise { return new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) } function failure(message: string): RemoteResult { return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) } } function fixture(prepareStream?: (stream: RemoteStream) => void, inputWindow?: RemoteUplinkWindow) { const attachments: StreamHandle[] = [] const sent: TerminalInput[][] = [] const mock = RemoteMock.create().stream('terminal/attach', async (args, stream) => { attachments.push(stream) const commands: TerminalInput[] = [] sent.push(commands) stream.push({ type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id: args[1] }, writable: true, ...inputWindow === undefined ? {} : { inputWindow } }) for await (const command of stream.uplink) commands.push(command as TerminalInput) }) const remote: TerminalRemote = { retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), attach: mock.remote.terminal.attach, } const options: RemoteStreamOptions[] = [] const streams: { dispose(): Promise; restart(): void }[] = [] const generation = createSnapshotStore[0]['generation']['getSnapshot']>>(undefined) const gateway: Pick = { $stream: (option) => { options.push(option) const stream = new RemoteStream({ generation }, option) streams.push(stream) prepareStream?.(stream) return stream }, } cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) const inventory = new TerminalInventory(() => remote.list(sessionId)) cleanups.push(() => inventory.dispose()) const model = new TerminalView(sessionId, remote, gateway, inventory, info.id) cleanups.push(() => model.dispose()) return { model, remote, gateway, options, streams, generation, attachments, sent } } async function mount(model: TerminalView) { const detach = model.mount() await model.refresh() return detach } function acknowledge(model: TerminalView): void { const render = model.state.getSnapshot().render if (render !== undefined) model.acknowledge(render.revision) } async function connected(model: TerminalView): Promise { await mount(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(true) acknowledge(model) } it('waits for render acknowledgement before publishing subsequent output', async () => { const { model, remote } = fixture() vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id, signal) { const frames: TerminalFrame[] = [ { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } }, { type: 'output', sequence: 1, data: 'first' }, { type: 'output', sequence: 2, data: 'second' }, ] for (const frame of frames) yield frame await untilAborted(signal) })) await mount(model) await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') acknowledge(model) await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 1 }) acknowledge(model) await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 }) }) it('treats carrier loss as disconnected only for the active attachment', async () => { const { model, options } = fixture() await connected(model) const first = options[0]! first.carrierFailed?.(new RemoteStreamCarrierError('offline')) expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', writable: false }) model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) first.carrierFailed?.(new RemoteStreamCarrierError('late old failure')) expect(model.state.getSnapshot().phase).toBe('connected') }) it.each([ ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'], ['sequence gap', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], ['repeated snapshot', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'snapshot', writable: true, sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], ] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { const { model, remote } = fixture() vi.mocked(remote.attach).mockImplementation(streamMethod(async function* () { yield* frames })) await mount(model) if (frames[0]?.type === 'snapshot') { await expect.poll(() => model.state.getSnapshot().render).toBeDefined() acknowledge(model) } await expect.poll(() => model.state.getSnapshot().error).toContain(message) expect(model.state.getSnapshot().writable).toBe(false) }) it('retains an exited screen and closes controls when its stream ends', async () => { const { model, remote } = fixture() vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id) { yield { type: 'snapshot', writable: true, sequence: 0, screen: 'last screen', info: { ...info, id } } yield { type: 'state', writable: true, info: { ...info, id, state: 'exited', exitCode: 3 } } })) await connected(model) await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) }) it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { const { model, remote } = fixture() vi.mocked(remote.attach).mockImplementationOnce(streamMethod(async function* (_sessionId, id) { yield { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } } })) await connected(model) await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) }) it('publishes remote control transfer without replacing the retained screen', async () => { const { model, remote } = fixture() vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id, signal) { yield { type: 'snapshot', writable: true, sequence: 0, screen: 'retained', info: { ...info, id } } yield { type: 'state', writable: false, info: { ...info, id } } await untilAborted(signal) })) await connected(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(false) expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) }) /** Pause delivery after the real Gateway iterator settles, before the model observes it. */ function deliveryBarrier() { const ready = Promise.withResolvers() const release = Promise.withResolvers() let held = false function prepare(stream: RemoteStream): void { if (held) return held = true const iterator = stream[Symbol.asyncIterator]() let first = true vi.spyOn(stream, Symbol.asyncIterator).mockReturnValue({ async next() { if (!first) return iterator.next() first = false try { return await iterator.next() } finally { ready.resolve(undefined) await release.promise } }, async return() { return iterator.return!() }, }) } cleanups.push(() => { release.resolve(undefined) }) return { ready: ready.promise, release: () => { release.resolve(undefined) }, prepare } } it('ignores an already delivered screen when its attachment is replaced before the model receives it', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) await mount(model) await barrier.ready model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) const current = model.state.getSnapshot() barrier.release() await model.rename('settled') expect(model.state.getSnapshot().render).toBe(current.render) expect(remote.attach).toHaveBeenCalledTimes(2) }) it('ignores an already settled stream error after a newer attachment has become writable', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) vi.mocked(remote.attach).mockImplementationOnce(streamMethod(async function* () { yield* [] throw new Error('retired attachment failed') })) await mount(model) await barrier.ready model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) barrier.release() await model.rename('settled') expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) }) it('releases a pending screen render on carrier generation cancellation and accepts the replacement screen', async () => { const { model, remote, streams } = fixture() await mount(model) await expect.poll(() => model.state.getSnapshot().render).toBeDefined() const revision = model.state.getSnapshot().render!.revision streams[0]!.restart() await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1) expect(remote.attach).toHaveBeenCalledTimes(2) expect(model.state.getSnapshot().writable).toBe(true) }) it('does not wait for the DOM callback of a screen whose generation was already cancelled at delivery', async () => { const barrier = deliveryBarrier() const { model, remote, streams } = fixture(barrier.prepare) await mount(model) await barrier.ready streams[0]!.restart() barrier.release() await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2) expect(remote.attach).toHaveBeenCalledTimes(2) expect(model.state.getSnapshot().writable).toBe(true) }) it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => { const { model, remote } = fixture() await connected(model) await model.rename(` ${info.title} `) expect(remote.rename).not.toHaveBeenCalled() await model.rename(' Build ') expect(model.state.getSnapshot()).toMatchObject({ title: 'Build', info: { title: 'Build' } }) vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost') await model.rename('Other') expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' }) await model.dispose() await model.rename('Ignored') expect(remote.rename).toHaveBeenCalledTimes(2) }) it('classifies a discovery carrier failure as disconnected and supports explicit retry', async () => { const { model, remote } = fixture() vi.mocked(remote.environment).mockRejectedValueOnce(new RemoteStreamCarrierError('offline')) await model.refresh() expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' }) expect(remote.create).not.toHaveBeenCalled() await model.refresh() expect(model.state.getSnapshot().info?.id).toBe(info.id) expect(remote.create).toHaveBeenCalledOnce() }) it('retains state when disposal overtakes successful allocation or a failed discovery', async () => { for (const operation of ['creation', 'discovery'] as const) { const { model, remote } = fixture() const creation = Promise.withResolvers>() const discovery = Promise.withResolvers>() if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise) else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) const loading = model.refresh() if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() await model.dispose() const before = model.state.getSnapshot() if (operation === 'creation') creation.resolve(success(info)) else discovery.reject(new Error('late discovery failure')) await loading expect(model.state.getSnapshot()).toBe(before) expect(remote.attach).not.toHaveBeenCalled() } }) it('ignores a successful rename after the view is disposed', async () => { const { model, remote } = fixture() await connected(model) const rename = Promise.withResolvers>() vi.mocked(remote.rename).mockReturnValueOnce(rename.promise) const pending = model.rename('Late') await model.dispose() const before = model.state.getSnapshot() rename.resolve(success(undefined)) await pending expect(model.state.getSnapshot()).toBe(before) }) it('allows retry after failed process cleanup and never reconnects while close is pending', async () => { const { model, remote } = fixture() await connected(model) const closing = Promise.withResolvers>() vi.mocked(remote.close).mockReturnValueOnce(closing.promise) const first = model.close() expect(model.close()).toBe(first) model.connect() expect(remote.attach).toHaveBeenCalledOnce() closing.resolve(failure('close refused')) await expect(first).rejects.toThrow('close refused') expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' }) await model.close() expect(model.state.getSnapshot()).toMatchObject({ phase: 'closed', writable: false }) expect(remote.close).toHaveBeenCalledTimes(2) }) it('does not publish a close result or reconnect after disposal', async () => { const { model, remote } = fixture() await connected(model) const closing = Promise.withResolvers>() vi.mocked(remote.close).mockReturnValueOnce(closing.promise) const pending = model.close() await model.dispose() const before = model.state.getSnapshot() closing.resolve(success(undefined)) await pending model.mount() model.connect() expect(model.state.getSnapshot()).toBe(before) expect(remote.attach).toHaveBeenCalledOnce() }) it('exposes a localized quota error and clears it after a successful retry', async () => { const { model, remote } = fixture() vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) }) await model.refresh() expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' }) await connected(model) expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) }) it('sends input and clamped resize in order without waiting for acknowledgements', async () => { const h = fixture() await connected(h.model) h.model.resize(80, 24) h.model.write('first') h.model.write('second') h.model.resize(500, 300) await expect.poll(() => h.sent[0]?.length).toBe(3) expect(h.sent[0]).toEqual([ { type: 'write', sequence: 1, data: 'first' }, { type: 'write', sequence: 2, data: 'second' }, { type: 'resize', sequence: 3, cols: 200, rows: 100 }, ]) }) it('releases the UTF-8 input budget only on acknowledgement', async () => { const h = fixture() vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) await connected(h.model) h.model.write('界界') await expect.poll(() => h.sent[0]?.length).toBe(1) h.attachments[0]!.push({ type: 'ack', sequence: 1 }) h.attachments[0]!.push({ type: 'state', info: { ...info, cols: 90 }, writable: true }) await expect.poll(() => h.model.state.getSnapshot().info?.cols).toBe(90) h.model.write('界界') await expect.poll(() => h.sent[0]?.length).toBe(2) h.model.write('a') expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) }) it('keeps output attached after an input refusal and stops further input', async () => { const h = fixture() await connected(h.model) h.model.write('before transfer') h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false) h.model.write('refused') h.model.resize(100, 30) h.attachments[0]!.push({ type: 'output', sequence: 1, data: 'still following' }) await expect.poll(() => h.model.state.getSnapshot().render?.frame).toMatchObject({ data: 'still following' }) expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined }) expect(h.remote.attach).toHaveBeenCalledOnce() expect(h.sent[0]).toHaveLength(1) }) it('discards the old generation budget and never replays unacknowledged input after reconnect', async () => { const h = fixture() h.generation.set({ id: 1, host: { home: '/home/fixture' } }) await connected(h.model) h.model.write('unconfirmed') await expect.poll(() => h.sent[0]?.length).toBe(1) h.generation.set(undefined) h.attachments[0]!.fail(new RemoteStreamCarrierError('connection lost')) await expect.poll(() => h.model.state.getSnapshot().phase).toBe('disconnected') h.model.write('offline') h.generation.set({ id: 2, host: { home: '/home/fixture' } }) await expect.poll(() => h.attachments.length).toBe(2) await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) acknowledge(h.model) h.model.write('fresh') await expect.poll(() => h.sent[1]?.length).toBe(1) expect(h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'fresh' }]) h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) expect(h.model.state.getSnapshot().writable).toBe(true) expect(h.remote.create).toHaveBeenCalledOnce() }) it('ignores controls before mount and after detach, then reuses the process on remount', async () => { const h = fixture() h.model.write('early') h.model.resize(100, 30) const unmount = await mount(h.model) await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) unmount() h.model.write('detached') h.model.resize(100, 30) expect(h.sent[0]).toEqual([]) h.model.mount() await expect.poll(() => h.remote.attach).toHaveBeenCalledTimes(2) expect(h.remote.create).toHaveBeenCalledOnce() }) it('rejects an out-of-order acknowledgement and reports synchronous send failures', async () => { const h = fixture() await connected(h.model) h.model.write('input') h.attachments[0]!.push({ type: 'ack', sequence: 2 }) await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput') h.model.connect() await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) const handle = vi.mocked(h.remote.attach).mock.results.at(-1)!.value as ReturnType vi.spyOn(handle, 'send').mockImplementation(() => { throw new Error('send failed') }) h.model.write('next') expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'send failed' }) }) it.each(['write', 'resize'] as const)('ignores empty input and stale render acknowledgements and refuses %s without limits', async (operation) => { const h = fixture() await connected(h.model) h.model.acknowledge(-1) h.model.write('') const state = h.model.state.getSnapshot() h.model.state.set({ ...state, info: undefined }) h.model.write('missing') h.model.resize(100, 30) expect(h.sent[0]).toEqual([]) h.model.state.set({ ...state, environment: undefined }) if (operation === 'write') h.model.write('no budget') else h.model.resize(100, 30) expect(h.sent[0]).toEqual([]) expect(h.model.state.getSnapshot().issue).toBe('inputFull') }) function encodedFrame(command: TerminalInput): number { return new TextEncoder().encode(JSON.stringify({ type: 'item', streamId: '00000000-0000-0000-0000-000000000000', value: command })).byteLength } function inputWindow(maxBytes: number): RemoteUplinkWindow { const command: TerminalInput = { type: 'write', sequence: 1, data: 'a' } return { maxBytes, frameOverheadBytes: encodedFrame(command) - new TextEncoder().encode(JSON.stringify(command)).byteLength } } it('waits for complete-frame capacity and sends queued write and resize commands in order', async () => { const commands: TerminalInput[] = [ { type: 'write', sequence: 1, data: '\u0001界' }, { type: 'resize', sequence: 2, cols: 90, rows: 25 }, { type: 'write', sequence: 3, data: 'last' }, ] const h = fixture(undefined, inputWindow(Math.max(...commands.map(encodedFrame)))) await connected(h.model) h.model.write('\u0001界') h.model.resize(90, 25) h.model.write('last') await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 1)) h.attachments[0]!.push({ type: 'ack', sequence: 1 }) await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 2)) h.attachments[0]!.push({ type: 'ack', sequence: 2 }) await expect.poll(() => h.sent[0]).toEqual(commands) expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined }) }) it('keeps a burst of short commands within the advertised inbox and resumes on acknowledgements', async () => { const window = inputWindow(262_144) const h = fixture(undefined, window) vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 65_536, maxBufferedBytes: 2_097_152 })) await connected(h.model) for (let index = 0; index < 3000; index++) h.model.write('a') await expect.poll(() => h.sent[0]?.length).toBe(2250) const first = h.sent[0]! expect(first.reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes) for (let sequence = 1; sequence <= 1000; sequence++) h.attachments[0]!.push({ type: 'ack', sequence }) await expect.poll(() => h.sent[0]?.length).toBe(3000) expect(h.sent[0]!.slice(1000).reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes) expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined }) }) it('counts queued resize frames against the Client buffer and cancels unsent commands on overflow', async () => { const command: TerminalInput = { type: 'resize', sequence: 1, cols: 90, rows: 25 } const bytes = encodedFrame(command) const h = fixture(undefined, inputWindow(bytes)) vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxBufferedBytes: 2 * bytes })) await connected(h.model) h.model.resize(90, 25) h.model.resize(91, 25) await expect.poll(() => h.sent[0]?.length).toBe(1) h.model.resize(92, 25) expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) expect(h.sent[0]).toEqual([command]) }) it('refuses a single escaped input frame larger than the carrier window before sending it', async () => { const h = fixture(undefined, inputWindow(150)) await connected(h.model) h.model.write('\u0001'.repeat(20)) expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) expect(h.sent[0]).toEqual([]) }) it('rejects acknowledgements for commands still waiting in the Client queue', async () => { const h = fixture(undefined, inputWindow(120)) await connected(h.model) h.model.write('a') h.model.write('b') await expect.poll(() => h.sent[0]?.length).toBe(1) h.attachments[0]!.push({ type: 'ack', sequence: 2 }) await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput') }) it('drops waiting commands when input is refused and never replays them on a new attachment', async () => { const h = fixture(undefined, inputWindow(120)) await connected(h.model) h.model.write('a') h.model.write('b') await expect.poll(() => h.sent[0]?.length).toBe(1) h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false) expect(h.sent[0]).toHaveLength(1) h.model.connect() await expect.poll(() => h.attachments.length).toBe(2) await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) acknowledge(h.model) h.model.write('c') await expect.poll(() => h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'c' }]) })