diff --git a/src/client/timelineSource.ts b/src/client/timelineSource.ts index 675ddde..28574ac 100644 --- a/src/client/timelineSource.ts +++ b/src/client/timelineSource.ts @@ -7,10 +7,10 @@ * - INLINE (older hosts, channel-less deployments, the baseline-gate * fallback): the wire value carries the collections in place. It passes * through untouched — no fetch ever happens. - * - SPLIT (current host with the detail channel live): the wire value is the + * - SPLIT (current host with the detail route live): the wire value is the * slim head (~1KB — every session.list row, control baseline, and push - * frame carries it whole). The collections arrive from the host's - * `/dsh-context` `detail` endpoint (host/detail.ts): one targeted read + * frame carries it whole). The collections arrive from the plugin's + * `/api/dsh-context/detail` fetch route (host/detail.ts): one targeted read * when the tab/modal first opens, then a debounced refetch whenever the * pushed `detailRev` outruns the served detail. Closed tabs fetch nothing. * @@ -29,12 +29,12 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import type { ContextTimeline, ContextTimelineDetail } from '../shared/types' import type { ClientCtx, SessionStandardProps } from './services' -import { asRecord, numOf, objectsOf, projectionOf, rpcCallOf, timelineOf } from './services' +import { asRecord, numOf, objectsOf, projectionOf, timelineOf } from './services' -// The channel/endpoint pair of host/detail.ts — re-declared here: the client -// bundle inlines every import, and the host module must never reach it. -const DETAIL_CHANNEL = '/dsh-context' -const DETAIL_ENDPOINT = 'detail' +// The detail route of host/detail.ts — re-declared here: the client bundle +// inlines every import, and the host module must never reach it. Same-origin +// POST under the harness's authenticated `/api` fence. +const DETAIL_ROUTE = '/api/dsh-context/detail' /** * Narrow the detail endpoint's payload to a render-safe value (the same @@ -46,8 +46,14 @@ export function detailOf(value: unknown): ContextTimelineDetail | null { const data = asRecord(value) if (data === null) return null if (typeof data.rev !== 'number' || !Number.isFinite(data.rev) || data.rev < 0) return null + // The slim head rides the payload for the Agent network's cold-node ring + // fetch; a missing or malformed head only degrades that composition (the + // detail cards never read it), so it drops out instead of rejecting the + // payload the cards consume. + const head = timelineOf(data.head) return { rev: data.rev, + ...(head !== null ? { head } : {}), requests: objectsOf(data.requests), events: objectsOf(data.events), nodes: objectsOf(data.nodes), @@ -61,28 +67,30 @@ export function detailOf(value: unknown): ContextTimelineDetail | null { } /** - * The detail reader over the harness's generic Connection RPC - * (`ctx.connection.rpc.call`) — resolved through the shared `rpcCallOf` - * reflect read (the same seam openPathVia/canOpenPathsOf use), so a hostile - * or absent connection service degrades to `undefined` instead of throwing. - * The returned thunk resolves the session's current detail, `null` when the - * session is not live anymore, and rejects on transport failure or a - * malformed payload (the store turns the two into the retryable state). + * The detail reader over the plugin's own `/api` fetch route (host/detail.ts). + * Same-origin POST through the harness's authenticated fence; the returned + * thunk resolves the session's current detail, `null` when the session is not + * live anymore, and rejects on transport failure or a malformed payload (the + * store turns the two into the retryable state). */ export function makeDetailFetcher( ctx: ClientCtx, sessionId: string, ): (() => Promise) | undefined { + void ctx if (sessionId === '') return undefined - const call = rpcCallOf(ctx) - if (call === undefined) return undefined return async () => { - const result = await call(DETAIL_CHANNEL, DETAIL_ENDPOINT, { sessionId }) - const r = asRecord(result) - if (r === null || r.ok !== true) throw new Error('dsh-context: detail rpc failed') + const response = await fetch(DETAIL_ROUTE, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }) + if (!response.ok) throw new Error(`dsh-context: detail route HTTP ${response.status}`) + const r = asRecord(await response.json()) + if (r === null || r.ok !== true) throw new Error('dsh-context: detail read failed') if (r.value === null) return null const detail = detailOf(r.value) - if (detail === null) throw new Error('dsh-context: detail rpc malformed') + if (detail === null) throw new Error('dsh-context: detail read malformed') return detail } } @@ -179,9 +187,9 @@ export class DetailStore { // read's settle re-arms when the wanted rev still outruns the served one. if (this.inFlight) return if (this.fetcher === undefined) { - // No connection face on this deployment: the typed failure arms the - // cards' note. (The slim head is only served when the host channel - // went live, so this is the exotic path.) + // No session id to read for: the typed failure arms the cards' note. + // (The slim head is only served when the host route went live, so this + // is the exotic path.) this.failed = true this.emit() return diff --git a/src/host/detail.ts b/src/host/detail.ts index 1ad6208..d15cf6f 100644 --- a/src/host/detail.ts +++ b/src/host/detail.ts @@ -1,5 +1,5 @@ /** - * The on-demand DETAIL channel of the split `contextTimeline` generation. + * The on-demand DETAIL route of the split `contextTimeline` generation. * * The projection's wire value is the slim head (fold.ts `buildTimelineHead`); * the heavy collections (per-request records, context events, the served @@ -9,83 +9,86 @@ * follow snapshot, and push frame whole (see shared/types.ts * `ContextTimelineDetail`). * - * The channel is the harness's generic Connection RPC (`ctx.connection.rpc`), - * the same transport the plugin used before the v0.9 projection migration — - * verified present on every supported baseline (0.1.2-rc.1+). The handler - * resolves the session through the harness's own ladder: a LIVE session's - * unit state comes straight off the registry's `stateOf` (no second fold); - * a session only ever VIEWED cold (prepared into the observation cache — - * `SessionStore.prepare` never enters it into the live store) is observed - * through `ctx.sessionQuery` and its immutable log folded from init (cheap: - * a cold session's log is static, and the client's per-session store reads - * it once per page view). A session that left the live set mid-request, a - * unit that never registered, or a session nothing can observe resolves to - * a typed `null` — the client keeps its last detail and offers a retry, - * never an unhandled rejection. + * The transport is Connection's exact Fetch-route registry + * (`ctx.connection.fetch.register`) — the same seam the harness's own file + * upload and media-reference routes mount through, riding the authenticated + * `/api` fence. The handler resolves the session through the harness's own + * ladder: a LIVE session's unit state comes straight off the registry's + * `stateOf` (no second fold); a session only ever VIEWED cold (prepared into + * the observation cache — `SessionStore.prepare` never enters it into the + * live store) is observed through `ctx.sessionQuery` and its immutable log + * folded from init (cheap: a cold session's log is static, and the client's + * per-session store reads it once per page view). A session that left the + * live set mid-request, a unit that never registered, or a session nothing + * can observe resolves to a typed `null` — the client keeps its last detail + * and offers a retry, never an unhandled rejection. * * Load order is never assumed: `watchDetailChannel` nests a `ctx.inject` on - * the two faces, so a connection service that activates AFTER this plugin - * still arms the channel (cordis replays the inject when the dependency set - * completes). The returned gate is read by the timeline unit's view at every - * serve, so the wire generation flips to slim the moment the channel goes - * live and flips back if it unloads — the client reconciles both (it detects - * the generation per value, timelineSource.ts). A deployment whose - * connection/sessions services never compose keeps the gate closed forever - * and serves the inline value unchanged. + * the two faces the registration reads (connection, sessions), so a service + * that activates AFTER this plugin still arms the route (cordis replays the + * inject when the dependency set completes). The returned gate is read by + * the timeline unit's view at every serve, so the wire generation flips to + * slim the moment the route goes live and flips back if it unloads — the + * client reconciles both (it detects the generation per value, + * timelineSource.ts). A deployment whose connection/sessions services never + * compose — or whose connection carries no fetch registry — keeps the gate + * closed forever and serves the inline value unchanged. */ import type { Context } from '@deepseek-ai/cordis' import type { FoldBounds } from './config' import { applyTimeline, buildTimelineDetail, createTimelineState } from './fold' -/** The plugin's generic Connection RPC channel (the pre-v0.9 name, kept). */ -export const DETAIL_CHANNEL = '/dsh-context' -/** The one endpoint the channel serves: the session's timeline detail. */ -export const DETAIL_ENDPOINT = 'detail' +/** The plugin's detail route, under the authenticated `/api` fence. */ +export const DETAIL_ROUTE = '/api/dsh-context/detail' -/** The channel's liveness, read by the timeline unit's view at every serve. */ +/** The route's liveness, read by the timeline unit's view at every serve. */ export interface DetailChannelGate { readonly live: boolean } -/** The host `connection` service, as far as the channel consumes it. */ +/** The host `connection` service, as far as the route consumes it. */ interface ConnectionHostFace { - rpc?: { + fetch?: { // The handler's result is awaited by the transport; the cold rung below // awaits the observation read. - handle?( - channel: string, - handler: (endpoint: string, payload: unknown) => unknown, - ): () => void + register?(route: { + path: string + methods: readonly string[] + requestBody: 'buffered' + fetch: (request: Request) => Promise + }): () => void } } -/** The host `sessions` service, as far as the channel consumes it (the strict-global-read idiom). */ +/** The host `sessions` service, as far as the route consumes it (the strict-global-read idiom). */ interface SessionsHostFace { get?(id: string): unknown } /** - * The host `sessionQuery` service, as far as the channel consumes it: the - * cold-session rung. A session that is only VIEWED is never entered into the - * live store (`SessionStore.prepare` with a persistence seed does not - * register it) — the observation reader carries it instead, with the full - * immutable log on `events`. + * The host `sessionQuery` service, as far as the route consumes it: the + * prepared-observation read that folds a session only ever VIEWED cold. */ interface SessionQueryFace { - observeSession?(id: string, options?: { projectionMode?: 'all' | 'none' }): Promise + observeSession?(id: string, options: unknown): Promise } -/** The RPC failure envelope the transport expects (ConnectionRpcFailure). */ -function failure(code: string, message: string): { ok: false; error: { code: string; message: string; details: object } } { - return { ok: false, error: { code, message, details: {} } } +/** One JSON reply of the detail route. */ +function reply(value: unknown): Response { + return Response.json(value, { headers: { 'cache-control': 'no-store' } }) +} + +/** The typed failure envelope (the same shape the Connection RPC carried). */ +function failure(code: string, message: string): Response { + return reply({ ok: false, error: { code, message } }) } /** - * Arm the detail endpoint whenever the connection and sessions services are + * Serve the detail route whenever the connection and sessions services are * both composed (see the module header for the load-order contract). The * registration rides the injected fiber: either service unloading withdraws - * the channel and closes the gate. + * the route and closes the gate. */ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChannelGate { const gate = { live: false } @@ -93,20 +96,23 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan const connection = c.get('connection') as ConnectionHostFace | undefined const sessions = c.get('sessions') as SessionsHostFace | undefined // Bind at extraction (an unbound hand-off loses `this` on the real faces). - const handle = typeof connection?.rpc?.handle === 'function' - ? connection.rpc.handle.bind(connection.rpc) + const register = typeof connection?.fetch?.register === 'function' + ? connection.fetch.register.bind(connection.fetch) : undefined const getSession = typeof sessions?.get === 'function' ? sessions.get.bind(sessions) : undefined - if (handle === undefined || getSession === undefined) return + if (register === undefined || getSession === undefined) return const projections = ctx.sessionProjections - const handler = async (endpoint: string, payload: unknown): Promise => { - if (endpoint !== DETAIL_ENDPOINT) { - return failure('dsh-context/unknown-endpoint', `unknown endpoint: ${endpoint}`) + const handler = async (request: Request): Promise => { + let sessionId: unknown + try { + const body: unknown = await request.json() + sessionId = body !== null && typeof body === 'object' + ? (body as { sessionId?: unknown }).sessionId + : undefined + } catch { + return failure('dsh-context/bad-request', 'body is not JSON') } - const sessionId = payload !== null && typeof payload === 'object' - ? (payload as { sessionId?: unknown }).sessionId - : undefined if (typeof sessionId !== 'string' || sessionId === '') { return failure('dsh-context/bad-request', 'missing sessionId') } @@ -118,9 +124,9 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan // cursor (no-op when the drive is current); never mutate the result. const state = projections.stateOf(session as never, 'contextTimeline') // The unit is absent only in the baseline-gated composition, which never - // installs this channel — a miss is defensive. - if (state === undefined) return { ok: true, value: null } - return { ok: true, value: buildTimelineDetail(state, bounds) } + // installs this route — a miss is defensive. + if (state === undefined) return reply({ ok: true, value: null }) + return reply({ ok: true, value: buildTimelineDetail(state, bounds) }) } // Cold session (viewed through a prepared observation, never entered // into the live store): observe it and fold the detail from its @@ -131,10 +137,10 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan const observe = typeof query?.observeSession === 'function' ? query.observeSession.bind(query) : undefined - if (observe === undefined) return { ok: true, value: null } + if (observe === undefined) return reply({ ok: true, value: null }) const observation = await observe(sessionId, { projectionMode: 'none' }) const events = (observation as { events?: unknown } | null)?.events - if (!Array.isArray(events)) return { ok: true, value: null } + if (!Array.isArray(events)) return reply({ ok: true, value: null }) let state = createTimelineState() try { for (const ev of events) state = applyTimeline(state, ev as never, bounds) @@ -142,19 +148,19 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan const dispose = (observation as { [Symbol.dispose]?: unknown } | null)?.[Symbol.dispose] if (typeof dispose === 'function') dispose.call(observation) } - return { ok: true, value: buildTimelineDetail(state, bounds) } + return reply({ ok: true, value: buildTimelineDetail(state, bounds) }) } catch (err) { return failure('gateway/internal', err instanceof Error ? err.message : String(err)) } } try { - c.effect(() => { - const unregister = handle(DETAIL_CHANNEL, handler) - return () => { - unregister() - } - }, 'dsh-context: detail channel') + c.effect(() => register({ + path: DETAIL_ROUTE, + methods: ['POST'], + requestBody: 'buffered', + fetch: request => handler(request), + }), 'dsh-context: detail route') } catch { // A hostile or rejecting registry must not take the plugin down — the // gate stays closed and the wire value stays inline. diff --git a/src/host/fold.ts b/src/host/fold.ts index c2d0302..a1786aa 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -1462,10 +1462,12 @@ export function buildTimelineHead(state: TimelineState): Snapshot { /** * The on-demand detail payload (host/detail.ts serves it off the live fold - * state): the heavy collections plus the revision marker the head carries. + * state): the heavy collections, the slim head at the SAME cut (the Agent + * network's cold-node ring fetch renders composition off it), and the + * revision marker the head carries. */ export function buildTimelineDetail(state: TimelineState, bounds: FoldBounds): ContextTimelineDetail { - return { rev: state.detailRev ?? 0, ...detailCollectionsOf(state, bounds) } + return { rev: state.detailRev ?? 0, head: buildTimelineHead(state), ...detailCollectionsOf(state, bounds) } } /** diff --git a/src/host/index.ts b/src/host/index.ts index bd6c5c0..d3922f0 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -68,9 +68,9 @@ export function apply(ctx: Context, config: Config): void { // load path refuses such events wholesale, permanently bricking the session. watchStepIdentity(ctx) // The split wire generation (detail.ts): the detail channel arms whenever - // the connection/sessions services compose (load order never assumed), and - // the unit's view reads the gate per serve — slim while the channel is - // live, inline otherwise. + // the connection/sessions/webServer services compose (load order never + // assumed), and the unit's view reads the gate per serve — slim while the + // channel is live, inline otherwise. const gate = watchDetailChannel(ctx, resolveBounds(config)) ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live)) ctx.sessionProjections.register(createContextHeadersDefinition(name => attribution.ownerOf(name))) diff --git a/src/shared/types.ts b/src/shared/types.ts index 673198d..989f9f4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -259,6 +259,17 @@ export interface Snapshot { */ export interface ContextTimelineDetail { rev: number + /** + * The slim wire head at the SAME fold cut as the collections: the + * composition scalars (`current`), the window/model envelope, and the + * precomputed counts. Sessions listed cold (never attached since the + * requesting unit last changed) carry no `contextTimeline` projection row + * for the browser's list reads, so the Agent network card fetches this + * head per node to render their composition rings. The host always serves + * it; optional so a payload missing it still serves the collections (the + * detail cards) and only the ring composition degrades. + */ + head?: ContextTimeline requests: RequestRecord[] events: ContextEventRecord[] nodes: SurfaceNode[] diff --git a/tests/client/components/contextModal.spec.ts b/tests/client/components/contextModal.spec.ts index faba1fe..c8b9c4b 100644 --- a/tests/client/components/contextModal.spec.ts +++ b/tests/client/components/contextModal.spec.ts @@ -43,6 +43,7 @@ const OPEN = (): boolean => true afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('ContextModal', () => { @@ -419,27 +420,23 @@ describe('ContextModal — the split generation', () => { assert.fail(message) } - test('the modal shares the detail channel: the browser lands the collections on open', async () => { - const ctx = new TestClientCtx({ - services: { - sessions: new TestSessions(), - connection: { - rpc: { - call: async () => ({ - ok: true, - value: { - rev: 1, - requests: [{ seq: 1, turn: 1, step: 1, time: 1, system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 21 }], - events: [], - nodes: [{ seq: 1, cat: 'assistant', tokens: 5, text: 'modal reply' }], - droppedNodes: 0, - archive: [], - }, - }), - }, + test('the modal shares the detail route: the browser lands the collections on open', async () => { + vi.stubGlobal('fetch', async () => ({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + value: { + rev: 1, + requests: [{ seq: 1, turn: 1, step: 1, time: 1, system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 21 }], + events: [], + nodes: [{ seq: 1, cat: 'assistant', tokens: 5, text: 'modal reply' }], + droppedNodes: 0, + archive: [], }, - }, - }) + }), + })) + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings) const head = timeline({ counts: { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 }, diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 900c415..91574fb 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -29,6 +29,7 @@ const kit = makeKit() afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) @@ -1238,13 +1239,10 @@ describe('ContextView — the split generation (slim head + detail channel)', () } } - /** A ctx whose connection.rpc.call serves (or fails) the detail endpoint. */ + /** Stub the global fetch to serve (or fail) the plugin's detail route. */ function slimCtx(serve: () => Promise): TestClientCtx { - return new TestClientCtx({ - services: { - connection: { rpc: { call: (_channel: string, _endpoint: string, _payload: unknown) => serve() } }, - }, - }) + vi.stubGlobal('fetch', async () => ({ ok: true, status: 200, json: async () => await serve() })) + return new TestClientCtx() } async function until(fn: () => boolean, message: string): Promise { @@ -1316,31 +1314,28 @@ describe('ContextView — the split generation (slim head + detail channel)', () describe('ContextView — the op-log generation (fileOps on the detail payload)', () => { test('the File Activity card renders the fold-derived ops, no conversation join needed', async () => { - const ctx = new TestClientCtx({ - services: { - connection: { - rpc: { - call: async () => ({ - ok: true, - value: { - rev: 1, - requests: [{ seq: 2, turn: 1, step: 1, time: T0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }], - events: [], - nodes: [], - droppedNodes: 0, - archive: [], - // The op log covers the full session — the conversation window - // join plays no role in this card on this generation. - fileOps: [ - { seq: 1, path: '/ws/README.md', kind: 'read', tool: 'read', err: false, added: 0, removed: 0, read: { start: 1, count: 12 } }, - { seq: 2, path: '/ws/src/a.ts', kind: 'write', tool: 'edit', err: false, added: 3, removed: 1 }, - ], - }, - }), - }, + vi.stubGlobal('fetch', async () => ({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + value: { + rev: 1, + requests: [{ seq: 2, turn: 1, step: 1, time: T0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }], + events: [], + nodes: [], + droppedNodes: 0, + archive: [], + // The op log covers the full session — the conversation window + // join plays no role in this card on this generation. + fileOps: [ + { seq: 1, path: '/ws/README.md', kind: 'read', tool: 'read', err: false, added: 0, removed: 0, read: { start: 1, count: 12 } }, + { seq: 2, path: '/ws/src/a.ts', kind: 'write', tool: 'edit', err: false, added: 3, removed: 1 }, + ], }, - }, - }) + }), + })) + const ctx = new TestClientCtx() const View = makeView(ctx) const m = await mount(h(View, { sessionId: 'sv-opslog', diff --git a/tests/client/timelineSource.spec.ts b/tests/client/timelineSource.spec.ts index 56c6e9c..9b3b568 100644 --- a/tests/client/timelineSource.spec.ts +++ b/tests/client/timelineSource.spec.ts @@ -23,6 +23,7 @@ import { mount, text } from './helpers/kit' afterEach(() => { resetTimelineDetailStores() vi.restoreAllMocks() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -52,11 +53,18 @@ function slimHead(rev: number, over: Record = {}): Record { status?: number; body?: unknown }): ClientCtx { + vi.stubGlobal('fetch', async () => { + const outcome = await behavior() + const status = outcome.status ?? 200 + return { ok: status === 200, status, json: async () => outcome.body } + }) + return asClientCtx(new TestClientCtx()) } /** Poll until the predicate holds (real timers ride the store's debounce). */ @@ -106,42 +114,59 @@ describe('detailOf', () => { // Absent stays absent (the legacy inline generation's marker). assert.equal(detailOf(detail(1))?.fileOps, undefined) }) + + test('the slim head rides through sanitized; a malformed head drops alone', () => { + // A well-formed head passes through (the Agent network's cold-ring fetch). + const served = detailOf(detail(2, { head: slimHead(2) })) + assert.ok(served?.head !== undefined) + assert.equal(served.head.current.total, 15) + assert.equal(served.head.model, 'm') + // A non-record head drops without rejecting the payload the cards consume. + const junkHead = detailOf(detail(2, { head: 'nope' })) + assert.ok(junkHead !== null) + assert.equal(junkHead.head, undefined) + assert.ok(junkHead.requests.length > 0) + // Absent stays absent. + assert.equal(detailOf(detail(2))?.head, undefined) + }) }) describe('makeDetailFetcher', () => { - test('no session id, no connection face, or a hostile read yields no fetcher', () => { - assert.equal(makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: null })), ''), undefined) - assert.equal(makeDetailFetcher(asClientCtx(new TestClientCtx()), 's1'), undefined, 'no connection service') - assert.equal(makeDetailFetcher(ctxWithCall(undefined), 's1'), undefined) - assert.equal(makeDetailFetcher(ctxWithCall('not-a-function'), 's1'), undefined) - const hostile = { get: () => { throw new Error('hostile') } } - assert.equal(makeDetailFetcher(hostile as unknown as ClientCtx, 's1'), undefined) + test('no session id yields no fetcher', () => { + assert.equal(makeDetailFetcher(ctxWithCall(() => ({ body: null })), ''), undefined) }) test('the envelope contract: not-ok rejects, null value means absent, malformed value rejects', async () => { - const failed = makeDetailFetcher(ctxWithCall(async () => ({ ok: false, error: { code: 'x', message: 'm', details: {} } })), 's1')! - await assert.rejects(() => failed(), /detail rpc failed/) + const failed = makeDetailFetcher(ctxWithCall(() => ({ body: { ok: false, error: { code: 'x', message: 'm', details: {} } } })), 's1')! + await assert.rejects(() => failed(), /detail read failed/) - const absent = makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: null })), 's1')! + const absent = makeDetailFetcher(ctxWithCall(() => ({ body: { ok: true, value: null } })), 's1')! assert.equal(await absent(), null) - const malformed = makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: { no: 'rev' } })), 's1')! - await assert.rejects(() => malformed(), /detail rpc malformed/) + const malformed = makeDetailFetcher(ctxWithCall(() => ({ body: { ok: true, value: { no: 'rev' } } })), 's1')! + await assert.rejects(() => malformed(), /detail read malformed/) - const transport = makeDetailFetcher(ctxWithCall(async () => { throw new Error('offline') }), 's1')! + const transport = makeDetailFetcher(ctxWithCall(() => { + throw new Error('offline') + }), 's1')! await assert.rejects(() => transport(), /offline/) + + // A non-200 answer (the route absent on an inline-generation host) rejects. + const missing = makeDetailFetcher(ctxWithCall(() => ({ status: 404, body: 'not found' })), 's1')! + await assert.rejects(() => missing(), /HTTP 404/) }) - test('a good read resolves the narrowed detail and rides the declared channel pair', async () => { - const seen: string[] = [] - const fetcher = makeDetailFetcher(ctxWithCall(async (channel: string, endpoint: string, payload: unknown) => { - seen.push(channel, endpoint) - assert.deepEqual(payload, { sessionId: 's1' }) - return { ok: true, value: detail(4) } - }), 's1')! - const d = await fetcher() + test('a good read resolves the narrowed detail and POSTs the declared route', async () => { + const seen: { url?: unknown; body?: unknown } = {} + vi.stubGlobal('fetch', async (url: unknown, init: { body: string }) => { + seen.url = url + seen.body = JSON.parse(String(init.body)) + return { ok: true, status: 200, json: async () => ({ ok: true, value: detail(4) }) } + }) + const d = await makeDetailFetcher(asClientCtx(new TestClientCtx()), 's1')!() assert.equal(d?.rev, 4) - assert.deepEqual(seen, ['/dsh-context', 'detail']) + assert.equal(seen.url, '/api/dsh-context/detail') + assert.deepEqual(seen.body, { sessionId: 's1' }) }) }) @@ -308,7 +333,7 @@ describe('DetailStore', () => { assert.equal(store.getSnapshot().failed, false) }) - test('without a fetcher the store types the failure (the exotic no-connection path)', async () => { + test('without a fetcher the store types the failure (the empty-session-id path)', async () => { const store = new DetailStore(undefined, 0) store.request(1) await settle() @@ -374,9 +399,9 @@ describe('DetailStore', () => { describe('detailStoreOf', () => { test('one store per session, shared by every consumer; reset drops the cache', () => { - const ctx = ctxWithCall(async () => ({ ok: true, value: detail(1) })) + const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(1) } })) const a = detailStoreOf(ctx, 's1') - assert.equal(a, detailStoreOf(ctxWithCall(async () => ({ ok: true, value: null })), 's1'), 'the session key wins, the first fetcher keeps serving') + assert.equal(a, detailStoreOf(ctxWithCall(() => ({ body: { ok: true, value: null } })), 's1'), 'the session key wins, the first fetcher keeps serving') assert.notEqual(a, detailStoreOf(ctx, 's2')) resetTimelineDetailStores() assert.notEqual(detailStoreOf(ctx, 's1'), a, 'a reset store is a fresh instance') @@ -408,9 +433,9 @@ function probeRead(container: HTMLElement, key: string): string { describe('useTimelineSource', () => { test('the inline generation passes through untouched and never fetches', async () => { let calls = 0 - const ctx = ctxWithCall(async () => { + const ctx = ctxWithCall(() => { calls++ - return { ok: true, value: detail(1) } + return { body: { ok: true, value: detail(1) } } }) const inline: ContextTimeline = { ok: true, @@ -436,7 +461,7 @@ describe('useTimelineSource', () => { }) test('no projection value keeps the loading surface', async () => { - const m = await mount(h(SourceProbe, { ctx: ctxWithCall(undefined), sessionId: 's1', value: undefined })) + const m = await mount(h(SourceProbe, { ctx: ctxWithCall(() => ({ body: null })), sessionId: 's1', value: undefined })) assert.equal(probeRead(m.container, 'state'), 'loading') assert.equal(probeRead(m.container, 'steps'), 'null') await m.unmount() @@ -444,16 +469,18 @@ describe('useTimelineSource', () => { test('the split generation: counters render off the head immediately, the detail lands through the channel', async () => { let calls = 0 - const ctx = ctxWithCall(async () => { + const ctx = ctxWithCall(() => { calls++ return { - ok: true, - value: detail(3, { - surfaceFloor: 2, - archiveFloor: 5, - fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }], - fileOpsFloor: 4, - }), + body: { + ok: true, + value: detail(3, { + surfaceFloor: 2, + archiveFloor: 5, + fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }], + fileOpsFloor: 4, + }), + }, } }) const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(3) })) @@ -473,7 +500,7 @@ describe('useTimelineSource', () => { test('a rev bump on the pushed head refetches the detail', async () => { let served = 3 - const ctx = ctxWithCall(async () => ({ ok: true, value: detail(served) })) + const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(served) } })) const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(3) })) await until(() => probeRead(m.container, 'state') === 'ready', 'the first read never landed') assert.equal(detailStoreOf(ctx, 's1').getSnapshot().detail?.rev, 3) @@ -489,10 +516,10 @@ describe('useTimelineSource', () => { test('the failed read arms the retry affordance, which refires the read', async () => { let online = false let calls = 0 - const ctx = ctxWithCall(async () => { + const ctx = ctxWithCall(() => { calls++ if (!online) throw new Error('offline') - return { ok: true, value: detail(1) } + return { body: { ok: true, value: detail(1) } } }) const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(1) })) await until(() => probeRead(m.container, 'state') === 'failed', 'the failure never surfaced') @@ -506,7 +533,7 @@ describe('useTimelineSource', () => { }) test('an absent answer (the session left the live set) surfaces the failed note', async () => { - const ctx = ctxWithCall(async () => ({ ok: true, value: null })) + const ctx = ctxWithCall(() => ({ body: { ok: true, value: null } })) const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(1) })) await until(() => probeRead(m.container, 'state') === 'failed', 'absence never surfaced') await m.unmount() diff --git a/tests/host/detail.spec.ts b/tests/host/detail.spec.ts index 4246550..de442b5 100644 --- a/tests/host/detail.spec.ts +++ b/tests/host/detail.spec.ts @@ -1,14 +1,14 @@ -// The on-demand detail channel (src/host/detail.ts): the nested-inject -// gating on the connection/sessions faces (load-order independent), the -// gate's live flip on unload, and the endpoint's typed outcomes — the detail -// payload off the live fold state, the typed `null` for a gone session or an -// absent unit, and the failure envelope for bad payloads and hostile reads -// (the channel must never throw into the transport). +// The on-demand detail route (src/host/detail.ts): the nested-inject gating +// on the connection/sessions faces (load-order independent), the gate's live +// flip on unload, and the route's typed outcomes — the detail payload off the +// live fold state, the typed `null` for a gone session or an absent unit, and +// the failure envelope for bad payloads and hostile reads (the route must +// never throw into the transport). import assert from 'node:assert/strict' import { describe, test } from 'vitest' import type { Context } from '@deepseek-ai/cordis' -import { watchDetailChannel } from '../../src/host/detail' +import { DETAIL_ROUTE, watchDetailChannel } from '../../src/host/detail' import { resolveBounds } from '../../src/host/config' import type { TimelineState } from '../../src/host/fold' import { assistantMessage, header, userMessage } from './helpers/events' @@ -16,7 +16,7 @@ import { driveTimeline } from './helpers/projection' const BOUNDS = resolveBounds({}) -type Handler = (endpoint: string, payload: unknown) => unknown +type RouteFetch = (request: Request) => Promise interface CtxSpec { connection?: unknown @@ -30,8 +30,8 @@ interface CtxSpec { * once its dependency list completes (here: immediately when the services * map has them, never otherwise), and its returned disposer is collected. */ -function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; handler?: Handler }; disposers: (() => void)[] } { - const captured: { channel?: string; handler?: Handler } = {} +function ctxOf(spec: CtxSpec): { ctx: Context; captured: { path?: string; fetch?: RouteFetch }; disposers: (() => void)[] } { + const captured: { path?: string; fetch?: RouteFetch } = {} const disposers: (() => void)[] = [] const services = new Map() if ('connection' in spec) services.set('connection', spec.connection) @@ -51,13 +51,13 @@ function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; han if (typeof d === 'function') disposers.push(d as () => void) }, } - // Wire the connection face so handle() captures its registration. + // Wire the connection face so register() captures its route. if (spec.connection !== undefined && spec.connection !== null) { - const conn = spec.connection as { rpc?: { handle?: unknown } } - if (typeof conn.rpc?.handle === 'function') { - conn.rpc.handle = (channel: string, handler: Handler) => { - captured.channel = channel - captured.handler = handler + const conn = spec.connection as { fetch?: { register?: unknown } } + if (typeof conn.fetch?.register === 'function') { + conn.fetch.register = (route: { path: string; fetch: RouteFetch }) => { + captured.path = route.path + captured.fetch = route.fetch return () => {} } } @@ -65,6 +65,27 @@ function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; han return { ctx: ctx as unknown as Context, captured, disposers } } +/** POST one JSON body to the captured route and parse the JSON reply. */ +async function call(captured: { fetch?: RouteFetch }, body: unknown): Promise> { + const request = new Request(`http://dsh.test${DETAIL_ROUTE}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + const response = await captured.fetch!(request) + return await response.json() as Record +} + +/** POST one raw (non-JSON) body to the captured route. */ +async function callRaw(captured: { fetch?: RouteFetch }, body: string): Promise> { + const request = new Request(`http://dsh.test${DETAIL_ROUTE}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }) + return await captured.fetch!(request).then(r => r.json()) as Record +} + /** A live sessions face whose get() serves the `s1` id only. */ function sessionsWith(session: unknown): { get(id: string): unknown } { return { get: (id: string) => (id === 's1' ? session : undefined) } @@ -81,25 +102,25 @@ describe('watchDetailChannel gating', () => { // Sessions only. assert.equal(watchDetailChannel(ctxOf({ sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false) // Connection only. - const connOnly = ctxOf({ connection: { rpc: { handle: () => () => {} } } }) + const connOnly = ctxOf({ connection: { fetch: { register: () => () => {} } } }) assert.equal(watchDetailChannel(connOnly.ctx, BOUNDS).live, false) - // A connection without the rpc.handle face, or a sessions without get. - assert.equal(watchDetailChannel(ctxOf({ connection: { rpc: {} }, sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false) + // A connection without the fetch.register face, or a sessions without get. + assert.equal(watchDetailChannel(ctxOf({ connection: { fetch: {} }, sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false) assert.equal(watchDetailChannel(ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: { get: 42 }, }).ctx, BOUNDS).live, false) }) test('the gate opens on registration and closes on unload', () => { const { ctx, captured, disposers } = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), }) const gate = watchDetailChannel(ctx, BOUNDS) assert.equal(gate.live, true) - assert.equal(captured.channel, '/dsh-context') - assert.equal(typeof captured.handler, 'function') + assert.equal(captured.path, '/api/dsh-context/detail') + assert.equal(typeof captured.fetch, 'function') // Unload: the inject fiber's disposer closes the gate (the wire flips back to inline). for (const d of disposers) d() assert.equal(gate.live, false) @@ -110,8 +131,8 @@ describe('watchDetailChannel gating', () => { get: (name: string) => name === 'connection' ? { - rpc: { - handle: () => { + fetch: { + register: () => { throw new Error('registration rejected') }, }, @@ -132,41 +153,40 @@ describe('watchDetailChannel gating', () => { }) }) -describe('the detail endpoint', () => { - function liveCtx(state: TimelineState | undefined): { ctx: Context; captured: { handler?: Handler } } { +describe('the detail route', () => { + function liveCtx(state: TimelineState | undefined): { ctx: Context; captured: { fetch?: RouteFetch } } { return ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), stateOf: () => state, }) } - test('unknown endpoints and bad payloads fail with typed envelopes', async () => { + test('bad payloads fail with typed envelopes', async () => { const { ctx, captured } = liveCtx(undefined) watchDetailChannel(ctx, BOUNDS) - const handler = captured.handler! - assert.deepEqual(await handler('nope', { sessionId: 's1' }), { - ok: false, - error: { code: 'dsh-context/unknown-endpoint', message: 'unknown endpoint: nope', details: {} }, - }) - for (const payload of [null, 42, {}, { sessionId: 42 }, { sessionId: '' }]) { - const result = await handler('detail', payload) as { ok: boolean; error?: { code: string } } - assert.equal(result.ok, false, `payload ${JSON.stringify(payload)} rejected`) + for (const body of [null, 42, {}, { sessionId: 42 }, { sessionId: '' }]) { + const result = await call(captured, body) as { ok: boolean; error?: { code: string } } + assert.equal(result.ok, false, `payload ${JSON.stringify(body)} rejected`) assert.equal(result.error?.code, 'dsh-context/bad-request') } + // A non-JSON body degrades to the same typed rejection. + const raw = await callRaw(captured, 'not json') as { ok: boolean; error?: { code: string } } + assert.equal(raw.ok, false) + assert.equal(raw.error?.code, 'dsh-context/bad-request') }) test('a session outside the live set resolves to the typed null', async () => { const { state } = driveTimeline([userMessage(1, [{ type: 'text', text: 'hi' }], { kind: 'user' })]) const { ctx, captured } = liveCtx(state) watchDetailChannel(ctx, BOUNDS) - assert.deepEqual(await captured.handler!('detail', { sessionId: 'gone' }), { ok: true, value: null }) + assert.deepEqual(await call(captured, { sessionId: 'gone' }), { ok: true, value: null }) }) test('an absent unit state resolves to the typed null', async () => { const { ctx, captured } = liveCtx(undefined) watchDetailChannel(ctx, BOUNDS) - assert.deepEqual(await captured.handler!('detail', { sessionId: 's1' }), { ok: true, value: null }) + assert.deepEqual(await call(captured, { sessionId: 's1' }), { ok: true, value: null }) }) test('a cold session (never entered into the live store) folds its detail off the observed log', async () => { @@ -178,7 +198,7 @@ describe('the detail endpoint', () => { let disposed = 0 let observedOptions: unknown const { ctx, captured } = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), stateOf: () => undefined, sessionQuery: { @@ -191,24 +211,25 @@ describe('the detail endpoint', () => { }, }) watchDetailChannel(ctx, BOUNDS) - const result = await captured.handler!('detail', { sessionId: 'cold1' }) as { + const result = await call(captured, { sessionId: 'cold1' }) as { ok: boolean - value: { rev: number; requests: unknown[]; nodes: unknown[] } | null + value: { rev: number; requests: unknown[]; nodes: unknown[]; head: { current: { total: number } } } | null } assert.equal(result.ok, true) assert.ok(result.value !== null) assert.equal(result.value.requests.length, 1, 'the observed log folded') assert.equal(result.value.nodes.length, 2) + assert.ok(result.value.head.current.total > 0, 'the slim head rides the cold fold (the graph ring fetch)') assert.deepEqual(observedOptions, { projectionMode: 'none' }, 'the observation skips projection work') assert.equal(disposed, 1, 'the observation lease disposed after the fold') // A session nothing can observe (the query resolves null) stays a typed null. - assert.deepEqual(await captured.handler!('detail', { sessionId: 'nobody' }), { ok: true, value: null }) + assert.deepEqual(await call(captured, { sessionId: 'nobody' }), { ok: true, value: null }) }) test('an observation without a dispose face still serves (nothing to release)', async () => { const events = [userMessage(1, [{ type: 'text', text: 'hi' }], { kind: 'user' })] const { ctx, captured } = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), stateOf: () => undefined, sessionQuery: { @@ -216,7 +237,7 @@ describe('the detail endpoint', () => { }, }) watchDetailChannel(ctx, BOUNDS) - const result = await captured.handler!('detail', { sessionId: 'cold1' }) as { ok: boolean; value: { nodes: unknown[] } | null } + const result = await call(captured, { sessionId: 'cold1' }) as { ok: boolean; value: { nodes: unknown[] } | null } assert.equal(result.ok, true) assert.equal(result.value?.nodes.length, 1) }) @@ -224,23 +245,23 @@ describe('the detail endpoint', () => { test('a sessionQuery without the observe face (or a rejecting read) degrades cleanly', async () => { // Face present but the method missing. const noFace = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), sessionQuery: {}, }) watchDetailChannel(noFace.ctx, BOUNDS) - assert.deepEqual(await noFace.captured.handler!('detail', { sessionId: 'cold' }), { ok: true, value: null }) + assert.deepEqual(await call(noFace.captured, { sessionId: 'cold' }), { ok: true, value: null }) // The observation read rejects (persistence down). const rejecting = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), sessionQuery: { observeSession: () => Promise.reject(new Error('persistence down')), }, }) watchDetailChannel(rejecting.ctx, BOUNDS) - const result = await rejecting.captured.handler!('detail', { sessionId: 'cold' }) as { ok: boolean; error: { code: string; message: string } } + const result = await call(rejecting.captured, { sessionId: 'cold' }) as { ok: boolean; error: { code: string; message: string } } assert.equal(result.ok, false) assert.equal(result.error.code, 'gateway/internal') assert.equal(result.error.message, 'persistence down') @@ -254,17 +275,18 @@ describe('the detail endpoint', () => { ]) const { ctx, captured } = liveCtx(state) watchDetailChannel(ctx, BOUNDS) - const result = await captured.handler!('detail', { sessionId: 's1' }) as { ok: true; value: { rev: number; requests: unknown[]; nodes: unknown[] } } + const result = await call(captured, { sessionId: 's1' }) as { ok: true; value: { rev: number; requests: unknown[]; nodes: unknown[]; head: { current: { total: number } } } } assert.equal(result.ok, true) assert.equal(result.value.rev, 2, 'two detail-mutating folds') assert.equal(result.value.requests.length, 1) assert.equal(result.value.nodes.length, 2) + assert.ok(result.value.head.current.total > 0, 'the slim head rides the live read too') }) test('hostile reads degrade to the failure envelope, never a throw', async () => { // sessions.get throwing. const throwingGet = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: { get: () => { throw new Error('hostile sessions') @@ -272,37 +294,37 @@ describe('the detail endpoint', () => { }, }) watchDetailChannel(throwingGet.ctx, BOUNDS) - const r1 = await throwingGet.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { code: string; message: string } } + const r1 = await call(throwingGet.captured, { sessionId: 's1' }) as { ok: boolean; error: { code: string; message: string } } assert.equal(r1.ok, false) assert.equal(r1.error.code, 'gateway/internal') assert.equal(r1.error.message, 'hostile sessions') // stateOf throwing. const throwingState = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), stateOf: () => { throw new Error('hostile registry') }, }) watchDetailChannel(throwingState.ctx, BOUNDS) - const r2 = await throwingState.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { message: string } } + const r2 = await call(throwingState.captured, { sessionId: 's1' }) as { ok: boolean; error: { message: string } } assert.equal(r2.ok, false) assert.equal(r2.error.message, 'hostile registry') // A state whose collections are junk (the detail builder throws). const junkState = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: sessionsWith(okSession()), stateOf: () => ({ requests: null }), }) watchDetailChannel(junkState.ctx, BOUNDS) - const r3 = await junkState.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean } + const r3 = await call(junkState.captured, { sessionId: 's1' }) as { ok: boolean } assert.equal(r3.ok, false) // A non-Error rejection stringifies into the envelope. const stringThrow = ctxOf({ - connection: { rpc: { handle: () => () => {} } }, + connection: { fetch: { register: () => () => {} } }, sessions: { get: () => { throw 'string boom' @@ -310,7 +332,7 @@ describe('the detail endpoint', () => { }, }) watchDetailChannel(stringThrow.ctx, BOUNDS) - const r4 = await stringThrow.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { message: string } } + const r4 = await call(stringThrow.captured, { sessionId: 's1' }) as { ok: boolean; error: { message: string } } assert.equal(r4.ok, false) assert.equal(r4.error.message, 'string boom') }) diff --git a/tests/host/fold-split.spec.ts b/tests/host/fold-split.spec.ts index 432e873..e479460 100644 --- a/tests/host/fold-split.spec.ts +++ b/tests/host/fold-split.spec.ts @@ -144,7 +144,7 @@ describe('buildTimelineHead', () => { }) describe('buildTimelineDetail', () => { - test('serves the same collections the inline view serves, plus the revision', () => { + test('serves the same collections the inline view serves, plus the revision and the head', () => { const { state, view } = driveTimeline(canonicalLog()) const detail = buildTimelineDetail(state, resolveBounds({})) assert.equal(detail.rev, 6) @@ -155,6 +155,11 @@ describe('buildTimelineDetail', () => { assert.deepEqual(detail.archive, view.archive) assert.equal(detail.surfaceFloor, view.surfaceFloor) assert.equal(detail.archiveFloor, view.archiveFloor) + // The slim head rides at the SAME cut: the composition the Agent + // network's cold-node ring fetch renders from. + assert.ok(detail.head !== undefined) + assert.deepEqual(detail.head.current, view.current) + assert.deepEqual(detail.head.counts, buildTimelineHead(state).counts) assertPlainJson(detail) // The detail never aliases the persisted state. assert.notEqual(detail.requests[0], state.requests[0]) @@ -206,7 +211,7 @@ describe('the split wire generation (definition slim flag)', () => { const detail = buildTimelineDetail(state, resolveBounds({})) const inline = timelineDef({}, false).wire.view(state) const { counts: _counts, last: _last, detailRev: _rev, ...headScalars } = head - const { rev: _detailRev, ...collections } = detail + const { rev: _detailRev, head: _detailHead, ...collections } = detail assert.deepEqual(inline, { ...headScalars, ...collections }) }) }) diff --git a/tests/host/index.spec.ts b/tests/host/index.spec.ts index d852aec..46385b1 100644 --- a/tests/host/index.spec.ts +++ b/tests/host/index.spec.ts @@ -184,13 +184,13 @@ describe('dsh-context host plugin', () => { assert.equal(snapshot.values.contextHeaders, undefined) }) - test('the split generation with the detail channel live: slim wire head + the endpoint serves the collections', async () => { + test('the split generation with the detail route live: slim wire head + the endpoint serves the collections', async () => { const ctx = new Context() - let handler: ((endpoint: string, payload: unknown) => Promise) | undefined + let route: { path?: string; fetch?: (request: Request) => Promise } | undefined ctx.provide('connection', { - rpc: { - handle: (_channel: string, h: never) => { - handler = h + fetch: { + register: (r: never) => { + route = r return () => {} }, }, @@ -214,8 +214,14 @@ describe('dsh-context host plugin', () => { assert.equal(timeline.nodes.length, 0, 'the collections stay off the wire value') assert.equal(timeline.requests.length, 0) - assert.ok(handler !== undefined, 'the detail channel registered') - const result = await handler('detail', { sessionId: session.header.id }) as { + assert.ok(route !== undefined && route.fetch !== undefined, 'the detail route registered') + assert.equal(route.path, '/api/dsh-context/detail') + const response = await route.fetch(new Request('http://dsh.test/api/dsh-context/detail', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: session.header.id }), + })) + const result = await response.json() as { ok: boolean value: { rev: number; nodes: unknown[]; requests: unknown[] } | null }