363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
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<JobResourceDto | null>()
|
|
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<typeof runtimeFixture>): Promise<void> {
|
|
await vi.waitFor(() => {
|
|
expect(fixture.runtime.snapshot.status).toBe('live')
|
|
})
|
|
}
|
|
|
|
async function flush(): Promise<void> {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
}
|
|
|
|
function deferred<T>(): {
|
|
promise: Promise<T>
|
|
resolve(value: T): void
|
|
} {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((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<void> {}
|
|
async signIn(): Promise<void> {}
|
|
async signOut(): Promise<void> {}
|
|
|
|
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<JobResourceDto | null> | null = null
|
|
listRequests = 0
|
|
ticketRequests = 0
|
|
readonly tokens: string[] = []
|
|
readonly jobRequests: string[] = []
|
|
|
|
async listJobs(accessToken: string): Promise<JobPageDto> {
|
|
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<JobResourceDto | null> {
|
|
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<DeletionOperationDto | null> {
|
|
return this.deletion
|
|
}
|
|
|
|
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
|
|
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<EventSocketListener['frame']>[0]): void {
|
|
this.#listener?.frame(frame)
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.#listener?.closed()
|
|
}
|
|
}
|
|
|
|
class ImmediateScheduler implements Scheduler {
|
|
schedule(callback: () => void): () => void {
|
|
queueMicrotask(callback)
|
|
return () => {}
|
|
}
|
|
}
|