Files
deepseek-harness/packages/test-support/client-runtime/src/sessions.ts
T

837 lines
30 KiB
TypeScript

/** Test-owned Session Controller faces over declarative fixtures. */
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment'
import {
createScope, MutableSessionEventSource, scopeOf, SESSION_SEARCH_RESULT_LIMIT,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
SessionReference, SessionReferenceSource, SessionRetainInfo, SessionRetainOptions,
SessionSnapshot, SessionSummary, SessionTarget, SubmissionHandle,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { scopeIdentityOf } from '@deepseek-ai/dsh-api-session-controller/src/client/scope.ts'
import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { sessionSnapshot } from './fixtures.ts'
import type {
SessionFixture, SessionFixtureSnapshot, Stabilizer,
} from './fixtures.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
testFixture: unknown
testView: unknown
testOperation: unknown
}
}
/**
* The fixture-backed session face: lifecycle reads delegate to the fixture's
* snapshot store; Session verbs are fail-loud stubs unless the
* fixture supplies them (the runtime never fakes behavior a test did not
* declare — an unstubbed call names itself instead of half-working). Extra
* fixture methods are grafted verbatim for feature-side casts.
*/
export class FixtureSession implements SessionFace {
/** Mutable event source consumed only by Conversation assembly. */
readonly eventSource = new MutableSessionEventSource()
/**
* Identity-stable per-key faces over fixture-controlled projection values.
*/
readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
/**
* @param sessionId - host identity (branded view of the fixture id).
* @param store - Session Controller snapshot store.
* @param overrides - fixture-declared behavior face, grafted over the stubs.
*/
constructor(
readonly sessionId: SessionId,
private readonly store: SnapshotStore<SessionFixtureSnapshot>,
overrides: Record<string, unknown>,
) {
const values = new Map<string, unknown>()
const listeners = new Map<string, Set<() => void>>()
const faces = new Map<string, ObservableSnapshot<unknown>>()
this.projections = {
faceOf: (key: string) => {
let face = faces.get(key)
if (face === undefined) {
face = {
getSnapshot: () => values.get(key),
subscribe: (fn: () => void) => {
const set = listeners.get(key) ?? new Set()
set.add(fn)
listeners.set(key, set)
return () => { set.delete(fn) }
},
}
faces.set(key, face)
}
return face
},
set: (key: string, value: unknown) => {
values.set(key, value)
for (const fn of [...(listeners.get(key) ?? [])]) fn()
},
}
Object.assign(this, overrides)
}
/** @returns the fixture Session Controller snapshot (useSession read side). */
getSnapshot(): SessionSnapshot {
return this.store.getSnapshot()
}
/**
* Subscribe to fixture snapshot changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
return this.store.subscribe(fn)
}
/**
* Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
prompt(): never {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Minimal local-echo registration: mints an identity without touching the
* fixture snapshot (submission echoes are client-only presentation state).
* Supply `beginSubmission` on the fixture's session face to observe echoes.
* @returns a handle whose abandon is a no-op.
*/
beginSubmission(): SubmissionHandle {
this.submissionSeq += 1
return {
requestId: `test-submission-${this.submissionSeq}` as SessionRequestId,
abandon: () => {},
}
}
private submissionSeq = 0
/**
* Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
* @param _attachmentId - opaque durable attachment id.
* @returns never — always throws.
*/
readAttachment(_attachmentId: AttachmentIdType): never {
throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
updateQueue(): never {
throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
cancel(): never {
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
command(): never {
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
loadOlder(): never {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `loadThrough` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
loadThrough(): never {
throw new Error(`test session "${this.sessionId}": loadThrough is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
rename(): never {
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
}
}
/** Catalog fixture data remains available across live Client generations. */
interface SessionRecord {
summary: SessionSummary
snapshot: SnapshotStore<SessionFixtureSnapshot>
session: FixtureSession
overrides: Record<string, unknown>
projections: Map<string, unknown>
initialOpen: SessionFixture['initialOpen']
}
interface SessionGeneration {
readonly binding: SessionBinding
readonly snapshot: SnapshotStore<SessionFixtureSnapshot>
readonly session: FixtureSession
readonly fiber: { dispose(): Promise<void> }
readonly lifetime: AbortController
readonly opening: Promise<void>
retention: SessionRetainInfo
live: boolean
}
interface OpeningControl {
readonly promise: Promise<void>
readonly resolve: () => void
readonly reject: (error: unknown) => void
}
function freezeRetainedBy(
counts: Partial<Record<SessionReferenceSource, number>>,
): SessionRetainInfo['retainedBy'] {
Object.setPrototypeOf(counts, null)
return Object.freeze(counts)
}
const EMPTY_RETAIN_INFO: SessionRetainInfo = Object.freeze({
referenceCount: 0,
retainedBy: freezeRetainedBy({}),
})
// TestSessions implements SessionReference against its fixture-owned
// SessionGeneration without importing the production service's private record.
/* jscpd:ignore-start -- The fixture intentionally mirrors production SessionReference settlement and release semantics. */
async function waitForOpen(opening: Promise<void>, signal?: AbortSignal): Promise<void> {
/* v8 ignore next -- TestSessionReference always supplies its release-composed signal. */
if (signal === undefined) return opening
const aborted = Promise.withResolvers<never>()
const onAbort = (): void => { aborted.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
try {
if (signal.aborted) onAbort()
await Promise.race([opening, aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
class TestSessionReference implements SessionReference {
private readonly released = new AbortController()
private readonly readiness = Promise.withResolvers<SessionBinding>()
readonly ready = this.readiness.promise
constructor(
readonly sessionId: SessionId,
private generation: SessionGeneration | undefined,
private releaseReference: (() => void) | undefined,
) {
void this.ready.catch(() => {})
}
get binding(): SessionBinding {
if (this.generation === undefined || !this.generation.live) {
throw new Error(`Session reference "${this.sessionId}" is released`)
}
return this.generation.binding
}
attachOpening(opening: Promise<void>, signal?: AbortSignal): void {
const waitSignal = signal === undefined
? this.released.signal
: AbortSignal.any([this.released.signal, signal])
void waitForOpen(opening, waitSignal).then(
() => {
try {
waitSignal.throwIfAborted()
this.readiness.resolve(this.binding)
} catch (error: unknown) {
this.readiness.reject(error)
}
},
(error: unknown) => { this.readiness.reject(error) },
)
}
release(): void {
const reason = new Error(`Session reference "${this.sessionId}" is released`)
const release = this.releaseReference
this.released.abort(reason)
this.readiness.reject(reason)
this.generation = undefined
this.releaseReference = undefined
release?.()
}
[Symbol.dispose](): void {
this.release()
}
}
/* jscpd:ignore-end */
/**
* Sessions test double behind the renderer host and feature injects: owns the
* catalog observable, scope minting through the production `createScope`,
* stable Controller bindings, and the session behavior face supplied per
* fixture. `ui-session` owns standard-source materialization.
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSessionSnapshot/event-window drivers/remove/
* behavior/calls/stubs) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions catalog feed, independent of view ownership. */
readonly list: SnapshotStore<SessionListState>
private readonly records = new Map<SessionId, SessionRecord>()
private readonly generations = new Map<SessionId, SessionGeneration>()
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly retentionStores = new Map<SessionId, SnapshotStore<SessionRetainInfo>>()
private readonly pendingDrops = new Set<Promise<void>>()
private closed = false
/** Calls observed on the service-level face, newest last. */
readonly calls: {
method: 'create' | 'refreshProjections' | 'refresh' | 'search' | 'fork'
args: unknown[]
}[] = []
/** The wire schema's `session.search` result bound (production parity). */
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
private createStub: ((opts: Parameters<ISessions['create']>[0]) => Promise<SessionId>) | undefined
/**
* @param stabilize - the owning runtime's act wrapper.
* @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
*/
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, phase: 'ready', projectionsBySession: {},
})
rootCtx.effect(() => async () => {
this.closed = true
for (const [id, generation] of this.generations) {
generation.live = false
generation.retention = EMPTY_RETAIN_INFO
generation.lifetime.abort(new Error('test Session Controller is disposed'))
this.publishRetention(id)
}
this.generations.clear()
await this.drainDrops()
}, 'test sessions: Client generations')
}
/**
* Add a Session fixture to the catalog without retaining a generation.
* @param fixture - identity + snapshot/summary overrides + behavior face.
* @returns the stable session id (branded view of `fixture.id`).
*/
async add(fixture: SessionFixture): Promise<SessionId> {
const id = fixture.id as SessionId
if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
const summary: SessionSummary = {
id,
displayTitle: fixture.id,
running: false,
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,
retainedBy: this.retentionSnapshot(id).retainedBy,
}
const snapshot = createSnapshotStore<SessionFixtureSnapshot>({
...sessionSnapshot(id),
...fixture.snapshot,
})
const session = new FixtureSession(id, snapshot, fixture.session ?? {})
if (fixture.events !== undefined || fixture.hasMore === true) {
session.eventSource.replace(fixture.events ?? [], fixture.hasMore ?? false)
}
this.records.set(id, {
summary,
snapshot,
session,
overrides: fixture.session ?? {},
projections: new Map(),
initialOpen: fixture.initialOpen,
})
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids.push(id)
draft.byId[id] = summary
})
})
return id
}
/**
* Update Session Controller lifecycle state through an immer draft.
* @param id - session id.
* @param mutate - draft mutator.
*/
async updateSessionSnapshot(
id: string,
mutate: (draft: SessionFixtureSnapshot) => void,
): Promise<void> {
const record = this.require(id)
await this.stabilize(() => {
record.snapshot.update(mutate)
this.generations.get(id as SessionId)?.snapshot.set(record.snapshot.getSnapshot())
})
}
/**
* Publish one complete projection value through the fixture Session face.
* @param id - session id.
* @param key - registered projection key.
* @param value - complete value for that key.
*/
async setProjection(id: string, key: string, value: unknown): Promise<void> {
const record = this.require(id)
record.projections.set(key, value)
await this.stabilize(() => {
record.session.projections.set(key, value)
this.generations.get(id as SessionId)?.session.projections.set(key, value)
})
}
/**
* Replace a Session's complete contiguous event window.
* @param id - Session identity.
* @param entries - complete event window.
* @param hasMore - whether older history remains.
*/
async replaceEvents(
id: string,
entries: readonly SessionEventLikeEntry[],
hasMore = false,
): Promise<void> {
await this.stabilize(() => {
this.require(id).session.eventSource.replace(entries, hasMore)
this.generations.get(id as SessionId)?.session.eventSource.replace(entries, hasMore)
})
}
/**
* Prepend one older contiguous event page.
* @param id - Session identity.
* @param entries - older entries.
* @param hasMore - whether another older page remains.
*/
async prependEvents(
id: string,
entries: readonly SessionEventLikeEntry[],
hasMore = false,
): Promise<void> {
await this.stabilize(() => {
this.require(id).session.eventSource.prepend(entries, hasMore)
this.generations.get(id as SessionId)?.session.eventSource.prepend(entries, hasMore)
})
}
/**
* Append one live event to a Session's contiguous window.
* @param id - Session identity.
* @param entry - live event entry.
*/
async appendEvent(id: string, entry: SessionLiveEventEntry): Promise<void> {
await this.stabilize(() => {
this.require(id).session.eventSource.append(entry)
this.generations.get(id as SessionId)?.session.eventSource.append(entry)
})
}
/**
* Update a session's list row (the wire-echo stand-in: title settles,
* running flips — components subscribed via useSessions re-render).
* @param id - session id.
* @param patch - summary fields to merge over the row.
*/
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
const record = this.require(id)
record.summary = {
...record.summary,
...patch,
retainedBy: this.retentionSnapshot(id as SessionId).retainedBy,
}
await this.stabilize(() => {
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
})
}
/**
* Remove a catalog row and mark its retained Session removed without releasing owners.
* @param id - session id.
*/
async remove(id: string): Promise<void> {
this.require(id)
this.records.delete(id as SessionId)
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids = draft.ids.filter(existing => existing !== id)
const { [id as SessionId]: _dead, ...rest } = draft.byId
draft.byId = rest
})
this.generations.get(id as SessionId)?.snapshot.update((draft) => { draft.removed = true })
})
}
/**
* Borrow the already-retained session-scoped Cordis context.
* @param id - session id.
* @returns the scoped context, or undefined without a live reference.
*/
scope(id: string): AgentContext | undefined {
return this.generations.get(id as SessionId)?.binding.ctx
}
/**
* Session assembly binding (inject factories and provide resolvers receive it).
* @param id - session id.
* @returns the live generation's binding, or undefined without a reference.
*/
binding(id: string): SessionBinding | undefined {
return this.generations.get(id as SessionId)?.binding
}
/* jscpd:ignore-start -- The fixture intentionally mirrors production SessionReference acquisition and release semantics. */
retain(
target: SessionTarget,
options: SessionRetainOptions = { source: 'testFixture' },
): SessionReference {
const { source, signal } = options
signal?.throwIfAborted()
if (this.closed) throw new Error('test Session Controller is disposed')
const id = this.resolveTarget(target)
const generation = this.generations.get(id) ?? this.materialize(id, this.require(id))
const reference = this.retainGeneration(id, generation, source)
try {
reference.attachOpening(generation.opening, signal)
return reference
} catch (error: unknown) {
reference.release()
throw error
}
}
async using<T>(
target: SessionTarget,
options: SessionRetainOptions,
operation: (reference: SessionReference) => T | Promise<T>,
): Promise<T> {
const reference = this.retain(target, options)
try {
await reference.ready
return await operation(reference)
} finally {
reference.release()
}
}
/* jscpd:ignore-end */
retainInfo(id: SessionId): ObservableSnapshot<SessionRetainInfo> {
let store = this.retentionStores.get(id)
if (store === undefined) {
store = createSnapshotStore(this.retentionSnapshot(id))
this.retentionStores.set(id, store)
}
return store
}
/**
* Retain one fixture Session until the supplied Cordis owner stops.
* @param ownerCtx - context whose disposal releases the reference.
* @param target - fixture Session identity or subagent address.
* @param options - reference source and optional readiness cancellation.
* @returns the owned reference immediately.
*/
retainFor(
ownerCtx: Context,
target: SessionTarget,
options: SessionRetainOptions = { source: 'testFixture' },
): SessionReference {
const reference = this.retain(target, options)
try {
ownerCtx.effect(() => () => { reference.release() }, 'test sessions: owned reference')
return reference
} catch (error: unknown) {
reference.release()
throw error
}
}
/**
* Read the session scope tag off a context (service-method boundary mirror).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
}
/**
* Resolve the scoped session face off a context (production `sessionOf`
* mirror).
* @param ctx - any client context.
* @returns the fixture session face, or undefined off-scope.
*/
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeOf(ctx)
if (id === undefined) return undefined
const generation = this.generations.get(id)
return generation !== undefined
&& scopeIdentityOf(generation.binding.ctx) === scopeIdentityOf(ctx)
? generation.session
: undefined
}
/**
* Install Session creation behavior for navigation tests.
* @param impl - implementation that must return an already-added fixture id.
*/
stubCreate(impl: (opts: Parameters<ISessions['create']>[0]) => Promise<SessionId>): void {
this.createStub = impl
}
/** Create through the installed test behavior and require a catalogued fixture. */
async create(opts?: Parameters<ISessions['create']>[0]): Promise<SessionId> {
this.calls.push({ method: 'create', args: [opts] })
if (this.createStub === undefined) {
throw new Error('test sessions: create is not stubbed — call stubCreate() first')
}
const id = await this.createStub(opts)
this.require(id)
return id
}
/** Resolve a retained or catalog-derived address independently of a view. */
subagentAddress(id: SessionId): SubagentAddress | undefined {
const retained = this.addresses.get(id)
if (retained !== undefined) return retained
for (const [parentSessionId, projections] of Object.entries(this.list.getSnapshot().projectionsBySession)) {
const child = projections.values.subagentCatalog?.find(entry => entry.id === id)
if (child !== undefined && child.mode !== 'external') {
return { parentSessionId: parentSessionId as SessionId, childSessionId: id, mode: child.mode }
}
}
return undefined
}
/** Record a projection refresh; fixture callers drive snapshots explicitly. */
refreshProjections(sessionId: SessionId): Promise<void> {
this.calls.push({ method: 'refreshProjections', args: [sessionId] })
return Promise.resolve()
}
/** Record a list refresh; fixture callers publish list state explicitly. */
refresh(): Promise<void> {
this.calls.push({ method: 'refresh', args: [] })
return Promise.resolve()
}
/**
* Replace the sidebar-search result page (the call is still recorded).
* @param impl - hits for a query, as the Host would rank them.
*/
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
this.searchStub = impl
}
/**
* Content search over the fixture corpus (recorded). The default answers an
* empty page: content ranking is Host behavior, so a scenario that asserts
* hits declares them through {@link TestSessions.stubSearch}.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search (recorded and forwarded).
* @returns the stubbed or empty result page.
*/
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
this.calls.push({ method: 'search', args: [query, signal] })
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).
* @param opts - source session id, optional cut anchor, and client title policy.
* @returns the source id (no child record is created).
*/
fork(opts: Parameters<ISessions['fork']>[0]): Promise<SessionId> {
this.calls.push({ method: 'fork', args: [opts] })
return Promise.resolve(opts.sessionId)
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).
* @param id - session id.
* @returns the FixtureSession carried by the Controller binding.
*/
behavior(id: string): FixtureSession {
return this.generations.get(id as SessionId)?.session ?? this.require(id).session
}
/** Dispose minted scope fibers (runtime dispose path). */
async disposeScopes(): Promise<void> {
this.closed = true
for (const [id, generation] of this.generations) this.drop(id, generation)
await this.drainDrops()
}
private resolveTarget(target: SessionTarget): SessionId {
const id = typeof target === 'string' ? target : target.childSessionId
if (typeof target !== 'string') {
this.addresses.set(id, target)
}
this.require(id)
return id
}
private retainGeneration(
id: SessionId,
generation: SessionGeneration,
source: SessionReferenceSource,
): TestSessionReference {
const previous = generation.retention
generation.retention = Object.freeze({
referenceCount: previous.referenceCount + 1,
retainedBy: freezeRetainedBy({
...previous.retainedBy,
[source]: (previous.retainedBy[source] ?? 0) + 1,
}),
})
const reference = new TestSessionReference(id, generation, () => {
if (!generation.live) return
const count = generation.retention.referenceCount - 1
const { [source]: sourceCount = 0, ...otherSources } = generation.retention.retainedBy
const retainedBy = sourceCount > 1
? { ...otherSources, [source]: sourceCount - 1 }
: otherSources
generation.retention = count === 0
? EMPTY_RETAIN_INFO
: Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) })
if (count === 0) this.drop(id, generation)
else this.publishRetention(id)
})
this.publishRetention(id)
return reference
}
private retentionSnapshot(id: SessionId): SessionRetainInfo {
return this.generations.get(id)?.retention ?? EMPTY_RETAIN_INFO
}
private publishRetention(id: SessionId): void {
const retention = this.retentionSnapshot(id)
const store = this.retentionStores.get(id)
if (store !== undefined && store.getSnapshot() !== retention) store.set(retention)
const state = this.list.getSnapshot()
const row = state.byId[id]
if (row === undefined || row.retainedBy === retention.retainedBy) return
const summary = { ...row, retainedBy: retention.retainedBy }
const record = this.records.get(id)
/* v8 ignore next -- a catalog row and its fixture record are inserted and removed together. */
if (record !== undefined) record.summary = summary
this.list.set({ ...state, byId: { ...state.byId, [id]: summary } })
}
private materialize(id: SessionId, fixture: SessionRecord): SessionGeneration {
const { ctx, fiber } = createScope(this.rootCtx, id)
const snapshot = createSnapshotStore(fixture.snapshot.getSnapshot())
const session = new FixtureSession(id, snapshot, fixture.overrides)
const window = fixture.session.eventSource.getSnapshot()
session.eventSource.replace(window.entries, window.hasMore)
for (const [key, value] of fixture.projections) session.projections.set(key, value)
const opening = Promise.withResolvers<void>()
void opening.promise.catch(() => {})
const lifetime = new AbortController()
const generation: SessionGeneration = {
binding: { sessionId: id, session, eventSource: session.eventSource, ctx },
snapshot,
session,
fiber,
lifetime,
opening: opening.promise,
retention: EMPTY_RETAIN_INFO,
live: true,
}
this.generations.set(id, generation)
ctx.effect(() => async () => {
if (generation.live) {
generation.live = false
generation.retention = EMPTY_RETAIN_INFO
if (this.generations.get(id) === generation) this.generations.delete(id)
this.publishRetention(id)
}
lifetime.abort(new Error(`test Session generation "${id}" is disposed`))
await Promise.allSettled([generation.opening])
}, 'test sessions: exact generation')
this.startOpening(fixture.initialOpen, lifetime.signal, opening)
return generation
}
private startOpening(
initialOpen: SessionFixture['initialOpen'],
signal: AbortSignal,
opening: OpeningControl,
): void {
try {
Promise.resolve(initialOpen?.(signal)).then(opening.resolve, opening.reject)
} catch (error: unknown) {
opening.reject(error)
}
}
private drop(id: SessionId, generation: SessionGeneration): void {
/* v8 ignore next -- only the live generation's retained callback can enter drop. */
if (!generation.live) return
generation.live = false
generation.retention = EMPTY_RETAIN_INFO
/* v8 ignore next -- this synchronous path drops only the generation currently stored for id. */
if (this.generations.get(id) === generation) this.generations.delete(id)
generation.lifetime.abort(new Error(`test Session generation "${id}" is released`))
this.publishRetention(id)
const disposal = generation.fiber.dispose()
this.pendingDrops.add(disposal)
void disposal.then(
() => { this.pendingDrops.delete(disposal) },
(error: unknown) => {
this.pendingDrops.delete(disposal)
this.rootCtx.logger.warn('test Session scope disposal failed:', error)
},
)
}
private async drainDrops(): Promise<void> {
while (this.pendingDrops.size !== 0) await Promise.all([...this.pendingDrops])
}
private require(id: string): SessionRecord {
const record = this.records.get(id as SessionId)
if (record === undefined) throw new Error(`test session "${id}" is not added`)
return record
}
}