Implement replay-safe browser synchronization

This commit is contained in:
ms2k 2026-07-18 00:16:21 +09:00
commit 459ffa04c6

View file

@ -0,0 +1,363 @@
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 () => {}
}
}