import { describe, expect, test, vi } from 'vitest' import type { EventConnection, EventSocket, EventSocketListener, PublicApi, } from '../api/client' import type { DeletionOperationDto, EventTicketDto, JobPageDto, JobResourceDto, } from '../api/contracts' import type { Session, SessionState } from '../auth/session' import { WebClientRuntime, type Scheduler } from './runtime' import { heartbeat, job, jobChanged, observedAt } from './test-fixtures' describe('web client runtime', () => { test('loads a snapshot, deduplicates events, and resyncs on a gap', async () => { const fixture = runtimeFixture() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20)) await vi.waitFor(() => { expect(fixture.api.jobRequests).toEqual(['job_00000001']) }) expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2)) fixture.socket.frame(jobChanged(2, 'event_000001', 2, 20)) await flush() expect(fixture.api.jobRequests).toHaveLength(1) fixture.socket.frame(heartbeat(4, 'event_000004')) await vi.waitFor(() => { expect(fixture.api.listRequests).toBe(2) }) }) test('clears state and ignores stale work on logout', async () => { const fixture = runtimeFixture() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.session.emit({ kind: 'signed_out' }) expect(fixture.runtime.snapshot.status).toBe('signed_out') expect(fixture.runtime.snapshot.jobs).toEqual([]) expect(fixture.socket.connections[0]?.closed).toBe(true) }) test('takes a fresh snapshot when authorization material changes', async () => { const fixture = runtimeFixture() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.api.snapshot = job(5, { label: 'New authorization' }) fixture.session.emit(authenticated('token-two')) await vi.waitFor(() => { expect(fixture.runtime.snapshot.status).toBe('live') expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual( fixture.api.snapshot, ) }) expect(fixture.api.tokens).toEqual(['token-one', 'token-two']) expect(fixture.socket.connections[0]?.closed).toBe(true) }) test('does not serialize a new session behind stale event work', async () => { const fixture = runtimeFixture() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.api.nextJob = new Promise(() => {}) fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20)) await vi.waitFor(() => { expect(fixture.api.jobRequests).toEqual(['job_00000001']) }) fixture.session.emit({ kind: 'signed_out' }) fixture.api.snapshot = job(5) fixture.session.emit(authenticated('token-two')) await live(fixture) fixture.api.nextJob = Promise.resolve(job(6)) fixture.socket.frame(jobChanged(1, 'event_000006', 6, 60)) await vi.waitFor(() => { expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(6)) }) }) test('refetches deletion invalidations and reconnects from the latest cursor', async () => { const fixture = runtimeFixture() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.socket.frame({ schema_version: 1, kind: 'deletion_changed', event_id: 'event_000001', sequence: 1, cursor: 'cursor-event-0001', occurred_at: observedAt, job_id: 'job_00000001', operation_id: 'deletion_001', state: 'running', }) await vi.waitFor(() => { expect(fixture.runtime.snapshot.deletions).toEqual([fixture.api.deletion]) }) fixture.socket.frame(heartbeat(2, 'event_000002')) await vi.waitFor(() => { expect(fixture.runtime.snapshot.cursor).toBe('cursor-event-0002') }) fixture.socket.disconnect() await vi.waitFor(() => { expect(fixture.socket.cursors).toEqual([ 'cursor-snapshot-0001', 'cursor-event-0002', ]) }) expect(fixture.api.ticketRequests).toBe(2) }) test('replays an invalidation whose refetch was interrupted by reconnect', async () => { const fixture = runtimeFixture() const staleRequest = deferred() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.api.nextJob = staleRequest.promise fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20)) await vi.waitFor(() => { expect(fixture.api.jobRequests).toEqual(['job_00000001']) }) fixture.socket.disconnect() await vi.waitFor(() => { expect(fixture.socket.cursors).toEqual([ 'cursor-snapshot-0001', 'cursor-snapshot-0001', ]) }) fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20)) await vi.waitFor(() => { expect(fixture.api.jobRequests).toHaveLength(2) expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2)) expect(fixture.runtime.snapshot.cursor).toBe('cursor-event-0001') }) staleRequest.resolve(job(9)) await flush() expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2)) }) test('does not serialize a reconnected socket behind a hung refetch', async () => { const fixture = runtimeFixture() await fixture.runtime.start() fixture.session.emit(authenticated('token-one')) await live(fixture) fixture.api.nextJob = new Promise(() => {}) fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20)) await vi.waitFor(() => { expect(fixture.api.jobRequests).toEqual(['job_00000001']) }) fixture.socket.disconnect() await vi.waitFor(() => { expect(fixture.socket.connections).toHaveLength(2) }) fixture.socket.frame({ schema_version: 1, kind: 'resync_required', event_id: 'event_000002', sequence: 1, cursor: 'cursor-event-0002', occurred_at: observedAt, reason: 'history_unavailable', }) await vi.waitFor(() => { expect(fixture.api.listRequests).toBe(2) expect(fixture.runtime.snapshot.status).toBe('live') }) }) }) function runtimeFixture(): { runtime: WebClientRuntime session: FakeSession api: FakeApi socket: FakeSocket } { const session = new FakeSession() const api = new FakeApi() const socket = new FakeSocket() return { runtime: new WebClientRuntime( session, api, socket, new ImmediateScheduler(), ), session, api, socket, } } async function live(fixture: ReturnType): Promise { await vi.waitFor(() => { expect(fixture.runtime.snapshot.status).toBe('live') }) } async function flush(): Promise { await Promise.resolve() await Promise.resolve() } function deferred(): { promise: Promise resolve(value: T): void } { let resolve!: (value: T) => void const promise = new Promise((complete) => { resolve = complete }) return { promise, resolve } } function authenticated(accessToken: string): SessionState { return { kind: 'authenticated', session: { accessToken, identity: 'issuer\u0000subject' }, } } class FakeSession implements Session { state: SessionState = { kind: 'loading' } readonly #listeners = new Set<(state: SessionState) => void>() async start(): Promise {} async signIn(): Promise {} async signOut(): Promise {} subscribe(listener: (state: SessionState) => void): () => void { this.#listeners.add(listener) listener(this.state) return () => { this.#listeners.delete(listener) } } emit(state: SessionState): void { this.state = state for (const listener of this.#listeners) listener(state) } } class FakeApi implements PublicApi { snapshot = job(1) readonly deletion: DeletionOperationDto = { id: 'deletion_001', job_id: 'job_00000001', state: 'running', problem: null, created_at: observedAt, updated_at: observedAt, } nextJob: Promise | null = null listRequests = 0 ticketRequests = 0 readonly tokens: string[] = [] readonly jobRequests: string[] = [] async listJobs(accessToken: string): Promise { this.listRequests += 1 this.tokens.push(accessToken) return { items: [this.snapshot], next_cursor: null, event_cursor: 'cursor-snapshot-0001', } } async getJob( _accessToken: string, jobId: string, ): Promise { this.jobRequests.push(jobId) if (this.nextJob !== null) { const response = this.nextJob this.nextJob = null return response } this.snapshot = job(2) return this.snapshot } async getDeletion( _accessToken: string, _operationId: string, ): Promise { return this.deletion } async createEventTicket(_accessToken: string): Promise { this.ticketRequests += 1 return { ticket: 'ticket_00001', expires_at: '2026-07-17T00:01:00Z', } } } interface FakeConnection extends EventConnection { closed: boolean } class FakeSocket implements EventSocket { readonly connections: FakeConnection[] = [] readonly cursors: string[] = [] #listener: EventSocketListener | null = null connect( _ticket: string, cursor: string, listener: EventSocketListener, ): EventConnection { this.#listener = listener const connection: FakeConnection = { closed: false, close(): void { connection.closed = true }, } this.connections.push(connection) this.cursors.push(cursor) listener.opened() return connection } frame(frame: Parameters[0]): void { this.#listener?.frame(frame) } disconnect(): void { this.#listener?.closed() } } class ImmediateScheduler implements Scheduler { schedule(callback: () => void): () => void { queueMicrotask(callback) return () => {} } }