import { describe, it, expect, beforeAll, beforeEach, afterEach, mock, spyOn } from 'bun:test' import { ref } from 'vue' type StorageEntry = ReturnType> type MockEventSourceMessage = { event: string id?: string data: string } type MockFetchEventSourceOptions = { method: string headers: Record credentials: RequestCredentials signal: AbortSignal openWhenHidden: boolean onopen: (response: Response) => Promise | void onmessage: (event: MockEventSourceMessage) => Promise | void onclose: () => Promise | void onerror: (error: unknown) => unknown } const runtimeConfig = { app: { baseURL: '/base-path', }, } ;(globalThis as typeof globalThis & { useRuntimeConfig?: () => typeof runtimeConfig }).useRuntimeConfig = () => runtimeConfig mock.module('#imports', () => ({ useRuntimeConfig: () => runtimeConfig, })) const storageMap = new Map>() const cloneValue = (value: T): T => { return JSON.parse(JSON.stringify(value)) as T } const useStorageFn = mock((key: string, defaultValue: T) => { if (!storageMap.has(key)) { storageMap.set(key, ref(cloneValue(defaultValue))) } return storageMap.get(key) as StorageEntry }) mock.module('@vueuse/core', () => ({ useStorage: useStorageFn, })) const fetchEventSourceMock = mock( async (_url: string, _options: MockFetchEventSourceOptions): Promise => {}, ) mock.module('@microsoft/fetch-event-source', () => ({ fetchEventSource: fetchEventSourceMock, })) type MockResponseInput = { ok: boolean status: number jsonData: unknown } const createMockResponse = ({ ok, status, jsonData }: MockResponseInput): Response => { return { ok, status, headers: new Headers({ 'Content-Type': 'application/json' }), redirected: false, statusText: ok ? 'OK' : 'Error', type: 'basic', url: '', body: null, bodyUsed: false, clone() { return this }, async json() { return jsonData }, text: async () => JSON.stringify(jsonData), arrayBuffer: async () => new ArrayBuffer(0), blob: async () => new Blob(), formData: async () => new FormData(), } as Response } let utils: Awaited let useConsoleSession: typeof import('~/composables/useConsoleSession').useConsoleSession beforeAll(async () => { utils = await import('~/utils/index') ;({ useConsoleSession } = await import('~/composables/useConsoleSession')) }) beforeEach(() => { storageMap.clear() useStorageFn.mockClear() fetchEventSourceMock.mockClear() }) afterEach(() => { const session = useConsoleSession() session.__resetForTesting() }) describe('useConsoleSession', () => { it('start_session', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-1', command: '--help', status: 'running' }, }), ) requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-1', command: '--help', status: 'completed', last_event_id: '2', exit_code: 0, }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) options.onmessage({ event: 'output', id: '1', data: JSON.stringify({ line: 'line one' }), }) options.onmessage({ event: 'close', id: '2', data: JSON.stringify({ exitcode: 0 }), }) }) const session = useConsoleSession() const started = await session.startSession({ command: '--help', displayCommand: '--help' }) await Promise.resolve() await Promise.resolve() expect(started).toBe(true) expect(session.state.value.sessionId).toBe('sess-1') expect(session.state.value.command).toBe('--help') expect(session.state.value.status).toBe('finished') expect(session.state.value.lastEventId).toBe('2') expect(session.state.value.exitCode).toBe(0) expect(session.state.value.transcript).toEqual([ 'user@YTPTube ~\n', '$ yt-dlp --help\n', 'line one\n', ]) requestSpy.mockRestore() }) it('restore_with_cursor', async () => { const session = useConsoleSession() session.state.value = { sessionId: 'sess-restore', command: '--version', status: 'running', lastEventId: '42', exitCode: null, transcript: ['user@YTPTube ~\n', '$ yt-dlp --version\n'], error: '', } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-restore', command: '--version', status: 'running', last_event_id: '42' }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) }) await session.restoreSession() expect(fetchEventSourceMock).toHaveBeenCalledTimes(1) const streamCall = fetchEventSourceMock.mock.calls[0] if (!streamCall) { throw new Error('Expected fetchEventSource to be called once.') } const [streamUrl, streamOptions] = streamCall expect(streamUrl).toContain('/base-path/api/system/terminal/sess-restore/stream?since=42') expect(streamOptions.headers['Last-Event-ID']).toBe('42') expect(streamOptions.method).toBe('GET') requestSpy.mockRestore() }) it('mark_expired_404', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-expired', command: '--help', status: 'running' }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen( new Response(JSON.stringify({ error: 'Session expired' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }), ) }) const session = useConsoleSession() const started = await session.startSession({ command: '--help', displayCommand: '--help' }) expect(started).toBe(true) expect(session.state.value.status).toBe('expired') expect(session.state.value.command).toBe('--help') expect(session.state.value.transcript).toEqual([ 'user@YTPTube ~\n', '$ yt-dlp --help\n', ]) expect(fetchEventSourceMock).toHaveBeenCalledTimes(1) requestSpy.mockRestore() }) it('restore_after_stream_error', async () => { const session = useConsoleSession() session.state.value = { sessionId: 'sess-reconnect', command: '--version', status: 'error', lastEventId: '7', exitCode: null, transcript: ['user@YTPTube ~\n', '$ yt-dlp --version\n'], error: 'Stream failed', } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-reconnect', command: '--version', status: 'running', last_event_id: '7' }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) }) await session.restoreSession() expect(fetchEventSourceMock).toHaveBeenCalledTimes(1) expect(session.state.value.status).toBe('running') expect(session.state.value.error).toBe('') requestSpy.mockRestore() }) it('dedupe_replayed_events', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-dupe', command: '--help', status: 'running' }, }), ) requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-dupe', command: '--help', status: 'completed', last_event_id: '9', exit_code: 0, }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) options.onmessage({ event: 'output', id: '8', data: JSON.stringify({ line: 'first line' }), }) options.onmessage({ event: 'output', id: '8', data: JSON.stringify({ line: 'first line' }), }) options.onmessage({ event: 'close', id: '9', data: JSON.stringify({ exitcode: 0 }), }) }) const session = useConsoleSession() await session.startSession({ command: '--help', displayCommand: '--help' }) await Promise.resolve() await Promise.resolve() expect(session.state.value.transcript).toEqual([ 'user@YTPTube ~\n', '$ yt-dlp --help\n', 'first line\n', ]) requestSpy.mockRestore() }) it('refresh_final_status', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-int', command: '--help', status: 'running' }, }), ) requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { session_id: 'sess-int', command: '--help', status: 'interrupted', last_event_id: '2', exit_code: -15, }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) options.onmessage({ event: 'output', id: '1', data: JSON.stringify({ line: 'partial output' }), }) options.onmessage({ event: 'close', id: '2', data: JSON.stringify({ exitcode: -15 }), }) }) const session = useConsoleSession() await session.startSession({ command: '--help', displayCommand: '--help' }) await Promise.resolve() await Promise.resolve() expect(session.state.value.status).toBe('interrupted') expect(session.state.value.exitCode).toBe(-15) expect(session.state.value.error).toBe('') requestSpy.mockRestore() }) it('cancel_without_reset', async () => { const session = useConsoleSession() session.state.value = { sessionId: 'sess-stop', command: '--help', status: 'running', lastEventId: '3', exitCode: null, transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n', 'chunk\n'], error: '', } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { message: 'Terminal session cancellation requested.', session_id: 'sess-stop' }, }), ) const result = await session.cancelSession() expect(result).toEqual({ status: 'cancelled' }) expect(session.state.value.sessionId).toBe('sess-stop') expect(session.state.value.status).toBe('running') expect(session.state.value.error).toBe('') const deleteCall = requestSpy.mock.calls.find( (call) => call[0] === '/api/system/terminal/sess-stop' && call[1]?.method === 'DELETE', ) expect(deleteCall).toEqual(['/api/system/terminal/sess-stop', { method: 'DELETE' }]) requestSpy.mockRestore() }) it('disconnect_local', async () => { const session = useConsoleSession() session.state.value = { sessionId: 'sess-detach', command: '--help', status: 'running', lastEventId: '3', exitCode: null, transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n'], error: '', } fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) return new Promise(() => {}) }) void session.restoreSession() await Promise.resolve() await Promise.resolve() const requestSpy = spyOn(utils, 'request') requestSpy.mockClear() session.disconnect() expect(requestSpy).toHaveBeenCalledTimes(0) expect(session.state.value.sessionId).toBe('sess-detach') requestSpy.mockRestore() }) it('refresh_missing_cancel', async () => { const session = useConsoleSession() session.state.value = { sessionId: 'sess-missing', command: '--help', status: 'running', lastEventId: '3', exitCode: null, transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n'], error: '', } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 404, jsonData: { error: 'Terminal session not found.' }, }), ) const result = await session.cancelSession() expect(result).toEqual({ status: 'missing', message: 'Terminal session not found.' }) expect(session.state.value.status).toBe('running') requestSpy.mockRestore() }) it('clear_transcript', () => { const session = useConsoleSession() session.state.value = { sessionId: 'sess-active', command: '--help', status: 'running', lastEventId: '15', exitCode: null, transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n', 'chunk\n'], error: '', } session.clearTranscript() expect(session.state.value.transcript).toEqual([]) expect(session.state.value.sessionId).toBe('sess-active') expect(session.state.value.status).toBe('running') expect(session.state.value.lastEventId).toBe('15') }) it('filter_hidden_recents', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [ { session_id: 'sess-newer', command: '--version', status: 'completed', created_at: 10, started_at: 11, finished_at: 20, expires_at: 100, available_until: 100, exit_code: 0, last_sequence: 2, }, { session_id: 'sess-hidden', command: '--help', status: 'running', created_at: 1, started_at: 2, finished_at: null, expires_at: null, available_until: null, exit_code: null, last_sequence: 1, }, ], }, }), ) const session = useConsoleSession() session.hideRecentSession('sess-hidden') await session.fetchRecentSessions() expect(session.recentSessions.value).toEqual([ { sessionId: 'sess-newer', command: '--version', status: 'completed', createdAt: 10, startedAt: 11, finishedAt: 20, expiresAt: 100, availableUntil: 100, exitCode: 0, lastSequence: 2, }, ]) requestSpy.mockRestore() }) it('replay_recent_session', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [ { session_id: 'sess-replay', command: '--help', status: 'completed', created_at: 10, started_at: 11, finished_at: 12, expires_at: 100, available_until: 100, exit_code: 0, last_sequence: 2, }, ], }, }), ) fetchEventSourceMock.mockImplementationOnce(async (_url, options) => { await options.onopen(new Response('', { status: 200 })) options.onmessage({ event: 'output', id: '1', data: JSON.stringify({ line: 'restored output' }), }) options.onmessage({ event: 'close', id: '2', data: JSON.stringify({ exitcode: 0 }), }) }) const session = useConsoleSession() const replayed = await session.replaySession({ sessionId: 'sess-replay', command: '--help', status: 'completed', createdAt: 10, startedAt: 11, finishedAt: 12, expiresAt: 100, availableUntil: 100, exitCode: 0, lastSequence: 2, }) await Promise.resolve() await Promise.resolve() expect(replayed).toBe(true) expect(session.state.value.sessionId).toBe('sess-replay') expect(session.state.value.transcript).toEqual(['restored output\n']) const streamCall = fetchEventSourceMock.mock.calls.at(-1) expect(streamCall?.[0]).toContain('/base-path/api/system/terminal/sess-replay/stream') requestSpy.mockRestore() }) })