The attachment-policy fixtures introduced three assertions to unknown and changed two previously recorded assertions. The new repository gate rejects those escape hatches even though the earlier branch CI passed. Give the user, tool-result, and inbox fixtures their required V4 message metadata and declared identifier types. Declare the fixture's plugin content tag through ContentBlockMap, and check the flat tool-result fixture directly as its concrete SessionEvent type. Keep intentional malformed inbox entries and opaque extension fields in the existing negative cases. Remove all five unknown bridges without adding allowances. Delete the two baseline entries retired by the rewritten existing fixtures; the other three assertions were never part of the accepted baseline. Attachment runtime code and the tests' access-denial and complete-log assertions remain unchanged. Validation: the Host TypeScript aggregate and all 52 archive tests pass. verify-no-unknown-casts reports no new or stale entries, with 1575 existing assertions remaining; the baseline adds zero allowances and retires two.
991 lines
45 KiB
TypeScript
991 lines
45 KiB
TypeScript
/**
|
|
* session.export host path: the GET download endpoint streams a ZIP whose
|
|
* files are the sessions' logical logs serialized as canonical JSONL (root +
|
|
* optional descendants) read through persistence read handles, and the
|
|
* degenerate compositions fail loudly (missing services → 500, missing root →
|
|
* 404, missing descendant → errored stream).
|
|
*/
|
|
|
|
import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session'
|
|
import { randomBytes } from 'node:crypto'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { unzipSync, strFromU8 } from 'fflate'
|
|
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
import type { SessionEvent, SessionHeader, SessionId, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-session'
|
|
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
|
import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence'
|
|
import type { SessionAccess, SessionHandle } from '@deepseek-ai/dsh-session-persistence'
|
|
import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
|
|
import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
|
|
import * as SessionLogExport from '../src/index.ts'
|
|
|
|
declare module '@deepseek-ai/dsh-llm' {
|
|
interface ContentBlockMap {
|
|
'plugin:vendor': { type: 'plugin:vendor'; data: { content: readonly unknown[] }; content: readonly unknown[] }
|
|
}
|
|
}
|
|
|
|
const sid = (id: string): SessionId => id as SessionId
|
|
const exportLogName = SessionLogExport.SESSION_LOG_FILENAME
|
|
const subagentLogName = (id: string): string => `subagents/${id}/${exportLogName}`
|
|
const generationLogName = (version: number): string => version === 0
|
|
? 'session.jsonl'
|
|
: `session.v${version}.jsonl`
|
|
|
|
function header(id: string, parentSession?: SessionId): SessionHeader {
|
|
return {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sid(id),
|
|
createdAt: 1000,
|
|
isSeeded: false,
|
|
cwd: '/proj',
|
|
...parentSession === undefined ? {} : { parentSession },
|
|
delegationDepth: parentSession === undefined ? 0 : 1,
|
|
}
|
|
}
|
|
|
|
/** One stored logical session log served by the fake persistence backend. */
|
|
interface StoredLog {
|
|
readonly header: SessionHeader
|
|
readonly events: readonly SessionEvent[]
|
|
}
|
|
|
|
const turnStart: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 2000, data: { turn: 1 } }
|
|
|
|
function log(id: string, parentSession?: SessionId, events: readonly SessionEvent[] = [turnStart]): StoredLog {
|
|
return { header: header(id, parentSession), events }
|
|
}
|
|
|
|
/** The expected zip text for one stored log: the canonical JSONL serialization. */
|
|
function logText(stored: StoredLog): string {
|
|
return SessionLogExport.serializeSessionLog(stored.header, stored.events)
|
|
}
|
|
|
|
function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
|
|
return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
|
|
}
|
|
|
|
/** One durable image object served by the fake attachment store. */
|
|
function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
|
|
return {
|
|
ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
|
|
data: new Uint8Array([1, 2, 3, 4]),
|
|
}
|
|
}
|
|
|
|
/** A user/message event carrying one image reference. */
|
|
function imageEvent(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): SessionEvent {
|
|
return {
|
|
type: 'user/message', seq: SessionSeq(1), time: 1000,
|
|
data: { content: [{ type: 'image', attachment: { attachmentId: id, mediaType, bytes: 4, width: 2, height: 2 } }] },
|
|
} as unknown as SessionEvent
|
|
}
|
|
|
|
/** A read handle over one stored log; only what readSessionLogText touches. */
|
|
function readHandle(stored: StoredLog): SessionHandle {
|
|
return {
|
|
id: stored.header.id,
|
|
header: stored.header,
|
|
access: 'read',
|
|
inheritedEventCount: 0,
|
|
read: async () => ({ eventState: 'detached', events: structuredClone(stored.events) }),
|
|
close: async () => {},
|
|
} as unknown as SessionHandle
|
|
}
|
|
|
|
/** A user/message event carrying one generic-file reference. */
|
|
function fileEvent(id: string, name = 'notes.txt', bytes = 5, seq = SessionSeq(1)): SessionEvent {
|
|
return {
|
|
type: 'user/message', seq, time: 1000,
|
|
data: { content: [{ type: 'file', attachment: { attachmentId: id, name, bytes } }] },
|
|
} as unknown as SessionEvent
|
|
}
|
|
|
|
async function buildApi(
|
|
logs: Record<string, StoredLog>,
|
|
descendants: SessionLineageNode[] = [],
|
|
services: {
|
|
query?: boolean
|
|
persistence?: boolean | 'throw'
|
|
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
|
|
readFileStream?: (ref: FileAttachmentRef, signal?: AbortSignal) => AsyncIterable<Uint8Array>
|
|
sessions?: {
|
|
get(id: SessionId): { readonly id: SessionId } | undefined
|
|
flush(session: { readonly id: SessionId }): Promise<boolean>
|
|
}
|
|
open?: (id: SessionId, access: SessionAccess, options?: { signal?: AbortSignal }) => Promise<SessionHandle>
|
|
traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
|
|
target: { header: SessionHeader; live: boolean; persisted: boolean }
|
|
ancestors: readonly SessionLineageNode[]
|
|
complete: boolean
|
|
root: { header: SessionHeader; live: boolean; persisted: boolean }
|
|
descendants: readonly SessionLineageNode[]
|
|
}>
|
|
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
|
} = {},
|
|
) {
|
|
const ctx = new Context()
|
|
ctx.provide('commands', { register: () => () => {} } as never)
|
|
const query = services.query ?? true
|
|
const persistence = services.persistence ?? true
|
|
if (query) {
|
|
ctx.provide('sessionQuery', {
|
|
traceSession: services.traceSession ?? (async () => ({
|
|
target: { header: header('session-root'), live: false, persisted: true },
|
|
ancestors: [],
|
|
complete: true,
|
|
root: { header: header('session-root'), live: false, persisted: true },
|
|
descendants,
|
|
})),
|
|
} as never)
|
|
}
|
|
if (persistence) {
|
|
ctx.provide('sessionPersistence', {
|
|
stat: async (id: SessionId) => {
|
|
// A custom `open` owns the scenario: absence must reach it, not stop here.
|
|
if (services.open !== undefined || persistence === 'throw') return { header: header(String(id)) }
|
|
const stored = logs[id]
|
|
return stored === undefined ? undefined : { header: stored.header }
|
|
},
|
|
open: services.open ?? (async (id: SessionId) => {
|
|
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
|
const stored = logs[id]
|
|
if (stored === undefined) throw new SessionPersistenceNotFoundError(id)
|
|
return readHandle(stored)
|
|
}),
|
|
} as never)
|
|
}
|
|
if (services.attachments !== false) {
|
|
const readImage = typeof services.attachments === 'function'
|
|
? services.attachments
|
|
: async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
|
|
ctx.provide('attachments', {
|
|
imageLimits: {} as never,
|
|
validateImage: async () => {},
|
|
saveImage: async () => { throw new Error('export never saves images') },
|
|
readImage,
|
|
readFileStream: services.readFileStream ?? (async function* () {
|
|
throw new Error('fixture has no files')
|
|
}),
|
|
} as never)
|
|
}
|
|
if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
|
|
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
|
|
const fiber = ctx.plugin(SessionLogExport, {
|
|
...services.compressionLevel === undefined
|
|
? {}
|
|
: { compressionLevel: services.compressionLevel },
|
|
})
|
|
await fiber.await()
|
|
const handler = connection.createSharedFetchHandler('/api')
|
|
return {
|
|
fetch: handler,
|
|
downloads: {
|
|
sessionLog: (
|
|
request: { sessionId: SessionId; includeDescendants: boolean },
|
|
signal: AbortSignal,
|
|
): Promise<Response> => {
|
|
const url = new URL(`http://host${SessionLogExport.SESSION_LOG_EXPORT_PATH}`)
|
|
url.searchParams.set('sessionId', request.sessionId)
|
|
url.searchParams.set('includeDescendants', String(request.includeDescendants))
|
|
return handler.fetch(new Request(url, { signal }))
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function toFetchHandler(api: Awaited<ReturnType<typeof buildApi>>): { fetch(request: Request): Promise<Response> } {
|
|
return api.fetch
|
|
}
|
|
|
|
async function responseBytes(response: Response): Promise<Uint8Array> {
|
|
return new Uint8Array(await response.arrayBuffer())
|
|
}
|
|
|
|
/** Minimal ready services for the direct streamSessionLogZip chunking tests. */
|
|
function directReady(): SessionLogExport.SessionLogExportReady {
|
|
return {
|
|
sessionQuery: { traceSession: async () => { throw new Error('unused') } } as never,
|
|
sessionPersistence: { open: async () => { throw new Error('unused') } } as never,
|
|
attachments: { readImage: async () => { throw new Error('no media') } } as never,
|
|
sessions: undefined,
|
|
}
|
|
}
|
|
|
|
/** Consume one directly built zip stream into its unpacked files. */
|
|
async function directZipFiles(rootContent: string): Promise<Record<string, Uint8Array>> {
|
|
const stream = SessionLogExport.streamSessionLogZip(
|
|
directReady(), rootContent, sid('session-root'), false, 6, new AbortController().signal,
|
|
)
|
|
return unzipSync(new Uint8Array(await new Response(stream).arrayBuffer()))
|
|
}
|
|
|
|
describe('session export compression config', () => {
|
|
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
|
expect(SessionLogExport.Config({})).toEqual({
|
|
compressionLevel: 6,
|
|
})
|
|
expect(SessionLogExport.Config({ compressionLevel: 0 }))
|
|
.toEqual({ compressionLevel: 0 })
|
|
expect(SessionLogExport.Config({ compressionLevel: 9 }))
|
|
.toEqual({ compressionLevel: 9 })
|
|
for (const value of [-1, 10, 1.5]) {
|
|
expect(() => SessionLogExport.Config({ compressionLevel: value } as never)).toThrow()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('serializeSessionLog', () => {
|
|
it('uses the canonical current-generation export filename', () => {
|
|
expect(SessionLogExport.SESSION_LOG_FILENAME).toBe(generationLogName(SESSION_FORMAT_VERSION))
|
|
})
|
|
|
|
it('writes the physical header line, one line per event, and a trailing newline', () => {
|
|
const stored = log('session-root')
|
|
expect(logText(stored)).toBe(
|
|
`${JSON.stringify({
|
|
type: 'session', version: SESSION_FORMAT_VERSION, id: sid('session-root'), createdAt: 1000,
|
|
cwd: '/proj', isSeeded: false, delegationDepth: 0,
|
|
})}\n${JSON.stringify(turnStart)}\n`,
|
|
)
|
|
})
|
|
|
|
it('serializes v2 lineage through the tagged marker and defaults delegationDepth to 0', () => {
|
|
const seeded: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sid('seeded'),
|
|
createdAt: 1000,
|
|
isSeeded: true,
|
|
parentSession: sid('parent'),
|
|
origin: 'subagent',
|
|
agentPreset: 'minimal',
|
|
}
|
|
const events: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
|
|
{ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
{ type: 'session/end-seed', seq: SessionSeq(2), time: 2, data: { inherited: true } },
|
|
]
|
|
expect(SessionLogExport.serializeSessionLog(seeded, events)).toBe(`${JSON.stringify({
|
|
type: 'session',
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sid('seeded'),
|
|
createdAt: 1000,
|
|
parentSession: sid('parent'),
|
|
isSeeded: true,
|
|
origin: 'subagent',
|
|
delegationDepth: 0,
|
|
agentPreset: 'minimal',
|
|
})}\n${events.map(event => JSON.stringify(event)).join('\n')}\n`)
|
|
})
|
|
})
|
|
|
|
describe('readSessionLogText', () => {
|
|
it('reads without a signal and maps only open not-found to undefined', async () => {
|
|
const stored = log('session-root')
|
|
const persistence = {
|
|
open: async (id: SessionId) => {
|
|
if (id !== stored.header.id) throw new SessionPersistenceNotFoundError(id)
|
|
return readHandle(stored)
|
|
},
|
|
} as never
|
|
await expect(SessionLogExport.readSessionLogText(persistence, sid('session-root')))
|
|
.resolves.toBe(logText(stored))
|
|
await expect(SessionLogExport.readSessionLogText(persistence, sid('absent')))
|
|
.resolves.toBeUndefined()
|
|
})
|
|
|
|
it('propagates a non-not-found open failure', async () => {
|
|
const persistence = {
|
|
open: async () => { throw new Error('EACCES: permission denied') },
|
|
} as never
|
|
await expect(SessionLogExport.readSessionLogText(persistence, sid('session-root')))
|
|
.rejects.toThrow('EACCES: permission denied')
|
|
})
|
|
})
|
|
|
|
describe('session.export download endpoint', () => {
|
|
it('streams a ZIP with the root log serialized as canonical JSONL', async () => {
|
|
const stored = log('session-root')
|
|
const api = await buildApi({ 'session-root': stored })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('content-type')).toBe('application/zip')
|
|
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files)).toEqual([exportLogName])
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(stored))
|
|
})
|
|
|
|
it('preflights root preparation through HEAD without streaming a body', async () => {
|
|
const open = vi.fn(async () => readHandle(log('session-root')))
|
|
const api = await buildApi({}, [], { open })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('content-type')).toBe('application/zip')
|
|
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
|
|
expect(response.body).toBeNull()
|
|
expect(open).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('returns a bodyless preparation error from HEAD', async () => {
|
|
const api = await buildApi({})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
|
|
)
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(response.body).toBeNull()
|
|
})
|
|
|
|
it('uses the resolved compression level for ZIP entries', async () => {
|
|
const filler = {
|
|
type: 'user/message', seq: SessionSeq(1), time: 1000,
|
|
data: { content: [{ type: 'text', text: 'compressible '.repeat(32 * 1024) }] },
|
|
} as unknown as SessionEvent
|
|
const stored = log('session-root', undefined, [turnStart, filler])
|
|
const storedApi = await buildApi({ 'session-root': stored }, [], { compressionLevel: 0 })
|
|
const compressedApi = await buildApi({ 'session-root': stored }, [], { compressionLevel: 9 })
|
|
const uncompressed = await storedApi.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: false },
|
|
new AbortController().signal,
|
|
)
|
|
const compressed = await compressedApi.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: false },
|
|
new AbortController().signal,
|
|
)
|
|
const storedBytes = await responseBytes(uncompressed)
|
|
const compressedBytes = await responseBytes(compressed)
|
|
expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
|
|
expect(strFromU8(unzipSync(compressedBytes)[exportLogName] as Uint8Array)).toBe(logText(stored))
|
|
})
|
|
|
|
it('includes descendant logs under subagents/<id>/ when requested', async () => {
|
|
const child = log('child-a', sid('session-root'))
|
|
const api = await buildApi({
|
|
'session-root': log('session-root'),
|
|
'child-a': child,
|
|
'grandchild-a': log('grandchild-a', sid('child-a')),
|
|
}, [
|
|
node('child-a', node('grandchild-a')),
|
|
])
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
|
)
|
|
expect(response.status).toBe(200)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files).sort()).toEqual([
|
|
exportLogName,
|
|
subagentLogName('child-a'),
|
|
subagentLogName('grandchild-a'),
|
|
])
|
|
expect(strFromU8(files[subagentLogName('child-a')] as Uint8Array))
|
|
.toBe(logText(child))
|
|
})
|
|
|
|
it('flushes each live root and descendant immediately before reading its log', async () => {
|
|
const staleMarker = { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } } as SessionEvent
|
|
const durableMarker = { type: 'turn/start', seq: SessionSeq(0), time: 2, data: { turn: 1 } } as SessionEvent
|
|
const stored: Record<string, StoredLog> = {
|
|
'session-root': log('session-root', undefined, [staleMarker]),
|
|
'child-a': log('child-a', sid('session-root'), [staleMarker]),
|
|
}
|
|
const durable: Record<string, StoredLog> = {
|
|
'session-root': log('session-root', undefined, [durableMarker]),
|
|
'child-a': log('child-a', sid('session-root'), [durableMarker]),
|
|
}
|
|
const flushed: SessionId[] = []
|
|
const api = await buildApi(stored, [node('child-a')], {
|
|
sessions: {
|
|
get: id => durable[id] === undefined ? undefined : { id },
|
|
flush: async (session) => {
|
|
const logAfterFlush = durable[session.id]
|
|
if (logAfterFlush === undefined) throw new Error('unexpected session')
|
|
flushed.push(session.id)
|
|
stored[session.id] = logAfterFlush
|
|
return true
|
|
},
|
|
},
|
|
})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(flushed).toEqual([sid('session-root'), sid('child-a')])
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(durable['session-root'] as StoredLog))
|
|
expect(strFromU8(files[subagentLogName('child-a')] as Uint8Array)).toBe(logText(durable['child-a'] as StoredLog))
|
|
})
|
|
|
|
it('reads a cold log without asking the live-session store to flush', async () => {
|
|
const flush = vi.fn(async () => true)
|
|
const stored = log('session-root')
|
|
const api = await buildApi({ 'session-root': stored }, [], {
|
|
sessions: {
|
|
get: () => undefined,
|
|
flush,
|
|
},
|
|
})
|
|
const response = await api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: false },
|
|
new AbortController().signal,
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(flush).not.toHaveBeenCalled()
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(stored))
|
|
})
|
|
|
|
it('answers 404 for a session the backend does not store', async () => {
|
|
const api = await buildApi({})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(404)
|
|
expect(await response.text()).toBe('session not found')
|
|
})
|
|
|
|
it('answers 400 when the sessionId query parameter is absent', async () => {
|
|
const api = await buildApi({ 'session-root': log('session-root') })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?includeDescendants=true'),
|
|
)
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('answers 400 for an includeDescendants value other than true or false', async () => {
|
|
const api = await buildApi({ 'session-root': log('session-root') })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
|
|
)
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
|
|
const api = await buildApi({}, [], { query: false, persistence: false })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(500)
|
|
expect(await response.text()).toContain('session-query')
|
|
})
|
|
|
|
it('fails the whole export when a descendant has no stored log', async () => {
|
|
const api = await buildApi({
|
|
'session-root': log('session-root'),
|
|
}, [node('child-missing')])
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
|
)
|
|
expect(response.status).toBe(200)
|
|
// The stream errors before completing, so the body read rejects rather
|
|
// than returning a truncated-but-valid archive.
|
|
await expect(response.arrayBuffer()).rejects.toThrow()
|
|
})
|
|
|
|
it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
|
|
// The push loop slices by 2^16 code units and must back off one unit when
|
|
// the boundary lands inside a surrogate pair; otherwise the pair re-encodes
|
|
// as U+FFFD and the exported log is silently corrupted.
|
|
const content = `${'a'.repeat((1 << 16) - 1)}😀tail`
|
|
const files = await directZipFiles(content)
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(content)
|
|
})
|
|
|
|
it('splits a long log on a plain code-unit boundary without backoff', async () => {
|
|
// A boundary that lands on a BMP character needs no surrogate backoff; the
|
|
// round trip must still be byte-identical across the multi-chunk push.
|
|
const content = 'z'.repeat((1 << 16) + 4096)
|
|
const files = await directZipFiles(content)
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(content)
|
|
})
|
|
|
|
it('streams an empty root text as an empty zip entry', async () => {
|
|
const files = await directZipFiles('')
|
|
expect(Object.keys(files)).toEqual([exportLogName])
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('')
|
|
})
|
|
|
|
it('preserves non-event JSON and absent content carriers without discovering attachment lookalikes', async () => {
|
|
const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png' } }]
|
|
const text = [null, [], false, { type: 'agent/inbox/spliced', data: { content } },
|
|
{ type: 'session/title-llm-request', data: { content } }].map(value => JSON.stringify(value)).join('\n')
|
|
const files = await directZipFiles(text)
|
|
expect(Object.keys(files)).toEqual([exportLogName])
|
|
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(text)
|
|
})
|
|
|
|
it('waits for response pull capacity before reading the next archive entry', async () => {
|
|
const filler = {
|
|
type: 'user/message', seq: SessionSeq(2), time: 1000,
|
|
data: { content: [{ type: 'text', text: randomBytes(512 * 1024).toString('base64') }] },
|
|
} as unknown as SessionEvent
|
|
const stored = log('session-root', undefined, [imageEvent('after-root'), filler])
|
|
let imageReads = 0
|
|
const api = await buildApi({ 'session-root': stored }, [], {
|
|
attachments: async (ref) => {
|
|
imageReads += 1
|
|
return storedImage(String(ref.attachmentId), ref.mediaType)
|
|
},
|
|
})
|
|
vi.useFakeTimers()
|
|
let response: Response | undefined
|
|
try {
|
|
response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
// Exhausting timer turns must not advance a producer whose byte queue is
|
|
// full; only a consumer pull can release it.
|
|
await vi.runAllTimersAsync()
|
|
expect(imageReads).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
if (response === undefined) throw new Error('missing export response')
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(imageReads).toBe(1)
|
|
expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
|
|
})
|
|
|
|
it('exports a shared lineage node once (seen-set dedup)', async () => {
|
|
const api = await buildApi({
|
|
'session-root': log('session-root'),
|
|
'child-a': log('child-a', sid('session-root')),
|
|
'child-b': log('child-b', sid('session-root')),
|
|
shared: log('shared', sid('child-a')),
|
|
}, [
|
|
node('child-a', node('shared')),
|
|
node('child-b', node('shared')),
|
|
])
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files).sort()).toEqual([
|
|
exportLogName,
|
|
subagentLogName('child-a'),
|
|
subagentLogName('child-b'),
|
|
subagentLogName('shared'),
|
|
])
|
|
})
|
|
|
|
it('answers 500 without leaking the backend error when the root read fails', async () => {
|
|
const api = await buildApi({}, [], { query: true, persistence: 'throw' })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(500)
|
|
const body = await response.text()
|
|
expect(body).toBe('session log export failed to read the stored log')
|
|
expect(body).not.toContain('/host/private/')
|
|
})
|
|
|
|
it('answers the private-error-safe 500 when the live root flush fails', async () => {
|
|
const api = await buildApi({ 'session-root': log('session-root') }, [], {
|
|
sessions: {
|
|
get: id => ({ id }),
|
|
flush: async () => { throw new Error('/host/private/flush-state') },
|
|
},
|
|
})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(500)
|
|
const body = await response.text()
|
|
expect(body).toBe('session log export failed to read the stored log')
|
|
expect(body).not.toContain('/host/private/')
|
|
})
|
|
|
|
it('forwards one request signal through root, lineage, and descendant reads', async () => {
|
|
const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
|
|
const traces: AbortSignal[] = []
|
|
const api = await buildApi({}, [node('child-a')], {
|
|
open: async (id, _access, options) => {
|
|
reads.push({ id, signal: options?.signal })
|
|
return readHandle(id === sid('session-root')
|
|
? log('session-root')
|
|
: log('child-a', sid('session-root')))
|
|
},
|
|
traceSession: async (_id, signal) => {
|
|
if (signal !== undefined) traces.push(signal)
|
|
return {
|
|
target: { header: header('session-root'), live: false, persisted: true },
|
|
ancestors: [],
|
|
complete: true,
|
|
root: { header: header('session-root'), live: false, persisted: true },
|
|
descendants: [node('child-a')],
|
|
}
|
|
},
|
|
})
|
|
const controller = new AbortController()
|
|
const response = await api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: true },
|
|
controller.signal,
|
|
)
|
|
await response.arrayBuffer()
|
|
const rootSignal = reads[0]?.signal
|
|
if (rootSignal === undefined) throw new Error('missing root signal')
|
|
const producerSignal = traces[0]
|
|
if (producerSignal === undefined) throw new Error('missing lineage signal')
|
|
expect(reads[0]?.id).toBe(sid('session-root'))
|
|
expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
|
|
const cancellation = new Error('request cancelled after response')
|
|
controller.abort(cancellation)
|
|
expect(rootSignal.aborted).toBe(true)
|
|
expect(rootSignal.reason).toBe(cancellation)
|
|
expect(producerSignal.aborted).toBe(true)
|
|
expect(producerSignal.reason).toBe(cancellation)
|
|
})
|
|
|
|
it('preserves request cancellation instead of translating it to HTTP 500', async () => {
|
|
const api = await buildApi({ 'session-root': log('session-root') })
|
|
const controller = new AbortController()
|
|
const cancellation = new Error('request cancelled')
|
|
controller.abort(cancellation)
|
|
await expect(api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: false },
|
|
controller.signal,
|
|
)).rejects.toBe(cancellation)
|
|
})
|
|
|
|
it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
|
|
let reportDescendantStarted!: (signal: AbortSignal) => void
|
|
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
|
reportDescendantStarted = resolve
|
|
})
|
|
const api = await buildApi({}, [node('child-a')], {
|
|
open: async (id, _access, options) => {
|
|
if (id === sid('session-root')) return readHandle(log('session-root'))
|
|
const signal = options?.signal
|
|
if (signal === undefined) throw new Error('missing descendant signal')
|
|
reportDescendantStarted(signal)
|
|
return new Promise((_, reject) => {
|
|
signal.addEventListener('abort', () => {
|
|
reject(signal.reason as Error)
|
|
}, { once: true })
|
|
})
|
|
},
|
|
})
|
|
const response = await api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: true },
|
|
new AbortController().signal,
|
|
)
|
|
const reader = response.body?.getReader()
|
|
if (reader === undefined) throw new Error('missing response body')
|
|
const descendantSignal = await descendantStarted
|
|
const cancellation = new Error('download consumer left')
|
|
await reader.cancel(cancellation)
|
|
expect(descendantSignal.aborted).toBe(true)
|
|
expect(descendantSignal.reason).toBe(cancellation)
|
|
})
|
|
|
|
it('aborts attachment reads when its reader cancels', async () => {
|
|
let reportAttachmentStarted!: (signal: AbortSignal) => void
|
|
const attachmentStarted = new Promise<AbortSignal>((resolve) => {
|
|
reportAttachmentStarted = resolve
|
|
})
|
|
const stored = log('session-root', undefined, [imageEvent('slow-img')])
|
|
const api = await buildApi({ 'session-root': stored }, [], {
|
|
attachments: async (_ref, signal) => {
|
|
if (signal === undefined) throw new Error('missing attachment signal')
|
|
reportAttachmentStarted(signal)
|
|
return new Promise((_, reject) => {
|
|
signal.addEventListener('abort', () => {
|
|
reject(signal.reason as Error)
|
|
}, { once: true })
|
|
})
|
|
},
|
|
})
|
|
const response = await api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: false },
|
|
new AbortController().signal,
|
|
)
|
|
const reader = response.body?.getReader()
|
|
if (reader === undefined) throw new Error('missing response body')
|
|
const attachmentSignal = await attachmentStarted
|
|
const cancellation = new Error('download consumer left during attachment read')
|
|
await reader.cancel(cancellation)
|
|
expect(attachmentSignal.aborted).toBe(true)
|
|
expect(attachmentSignal.reason).toBe(cancellation)
|
|
})
|
|
|
|
it('uses a stable Error reason when its reader cancels without one', async () => {
|
|
let reportDescendantStarted!: (signal: AbortSignal) => void
|
|
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
|
reportDescendantStarted = resolve
|
|
})
|
|
const api = await buildApi({}, [node('child-a')], {
|
|
open: async (id, _access, options) => {
|
|
if (id === sid('session-root')) return readHandle(log('session-root'))
|
|
const signal = options?.signal
|
|
if (signal === undefined) throw new Error('missing descendant signal')
|
|
reportDescendantStarted(signal)
|
|
return new Promise((_, reject) => {
|
|
signal.addEventListener('abort', () => {
|
|
reject(signal.reason as Error)
|
|
}, { once: true })
|
|
})
|
|
},
|
|
})
|
|
const response = await api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: true },
|
|
new AbortController().signal,
|
|
)
|
|
const reader = response.body?.getReader()
|
|
if (reader === undefined) throw new Error('missing response body')
|
|
const descendantSignal = await descendantStarted
|
|
await reader.cancel()
|
|
expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
|
|
})
|
|
|
|
it('normalizes a non-Error descendant failure before erroring the stream', async () => {
|
|
const api = await buildApi({}, [node('child-a')], {
|
|
open: async (id) => {
|
|
if (id === sid('session-root')) return readHandle(log('session-root'))
|
|
throw 'descendant read failed'
|
|
},
|
|
})
|
|
const response = await api.downloads.sessionLog(
|
|
{ sessionId: sid('session-root'), includeDescendants: true },
|
|
new AbortController().signal,
|
|
)
|
|
await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
|
|
})
|
|
|
|
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
|
|
const stored = log('session-root', undefined, [imageEvent('img-1')])
|
|
const api = await buildApi({ 'session-root': stored })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(200)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files).sort()).toEqual(['media/img-1.png', exportLogName].sort())
|
|
expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
|
|
})
|
|
|
|
it('streams generic files under their content-addressed archive paths', async () => {
|
|
const digest = 'a'.repeat(64)
|
|
const id = `sha256:${digest}`
|
|
const fallbackDigest = 'c'.repeat(64)
|
|
const fallbackId = `sha256:${fallbackDigest}`
|
|
const root = log('session-root', undefined, [
|
|
fileEvent(id, 'notes.txt', 5),
|
|
fileEvent(fallbackId, '.', 5, SessionSeq(2)),
|
|
])
|
|
const reads: Array<{ ref: FileAttachmentRef; signal: AbortSignal | undefined }> = []
|
|
const api = await buildApi({ 'session-root': root }, [], {
|
|
readFileStream: (ref, signal) => (async function* (): AsyncIterable<Uint8Array> {
|
|
reads.push({ ref, signal })
|
|
yield new Uint8Array()
|
|
yield Uint8Array.of(1, 2)
|
|
yield Uint8Array.of(3, 4, 5)
|
|
})(),
|
|
})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(files[`files/aa/${digest}/notes.txt`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5))
|
|
expect(files[`files/cc/${fallbackDigest}/file`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5))
|
|
expect(reads).toHaveLength(2)
|
|
expect(reads[0]?.ref).toMatchObject({ attachmentId: id, name: 'notes.txt', bytes: 5 })
|
|
expect(reads[0]?.signal).toBeInstanceOf(AbortSignal)
|
|
})
|
|
|
|
it('fails the whole export when a referenced file stream fails', async () => {
|
|
const digest = 'b'.repeat(64)
|
|
const id = `sha256:${digest}`
|
|
const root = log('session-root', undefined, [fileEvent(id)])
|
|
const api = await buildApi({ 'session-root': root }, [], {
|
|
readFileStream: () => (async function* (): AsyncIterable<Uint8Array> {
|
|
yield Uint8Array.of(1)
|
|
throw new Error('file bytes missing')
|
|
})(),
|
|
})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing')
|
|
})
|
|
|
|
it.each([
|
|
['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], ['team/message/queued', 'message'],
|
|
['tool/ptc-dispatch', 'content'],
|
|
['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'],
|
|
] as const)('exports attachments from the declared %s %s content', async (type, field) => {
|
|
const content = [{ type: 'image', attachment: { attachmentId: 'declared-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }]
|
|
const message = type === 'tool/result'
|
|
? { role: 'tool', toolCallId: 'declared-call', source: { kind: 'tool', callId: 'declared-call' }, content }
|
|
: { content }
|
|
const data = field === 'message' ? { message } : { [field]: content }
|
|
const stored = log('session-root', undefined, [{ type, seq: SessionSeq(1), time: 1000, data } as SessionEvent])
|
|
const api = await buildApi({ 'session-root': stored })
|
|
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files).sort()).toEqual(['media/declared-image.png', exportLogName].sort())
|
|
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
|
})
|
|
|
|
it('keeps malformed inbox entries opaque while exporting the complete log', async () => {
|
|
for (const value of [undefined, null, {}, [null, 1, []]]) {
|
|
const event = { type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 1000, data: { inserted: value } } as SessionEvent
|
|
const stored = log('session-root', undefined, [event])
|
|
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
|
|
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage })
|
|
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files)).toEqual([exportLogName])
|
|
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
|
expect(readImage).not.toHaveBeenCalled()
|
|
}
|
|
})
|
|
|
|
it('keeps unrelated event fields and content-block extension fields out of attachment collection', async () => {
|
|
const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }]
|
|
const event = { type: 'user/message', seq: SessionSeq(1), time: 1000, surfaceOp: 'append', data: {
|
|
id: 'metadata' as UserMessage['id'], role: 'user', source: { kind: 'user' },
|
|
content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }],
|
|
message: { content }, inserted: [{ content }],
|
|
stream: [{ type: 'chunk', chunk: { type: 'block-end', block: content[0] } }],
|
|
} } as SessionEvent
|
|
const stored = log('session-root', undefined, [event])
|
|
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
|
|
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage })
|
|
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files)).toEqual([exportLogName])
|
|
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
|
expect(readImage).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each(['external/image-record', 'session/title-llm-request'])('exports %s without reading attachments from its opaque payload', async (type) => {
|
|
const image = { type: 'image', attachment: { attachmentId: 'opaque-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }
|
|
const file = { type: 'file', attachment: { attachmentId: `sha256:${'e'.repeat(64)}`, name: 'opaque.txt', bytes: 5 } }
|
|
const content = [image, file]
|
|
const opaque = { type, seq: SessionSeq(1), time: 1000, ignorable: true, data: {
|
|
content, message: { content }, inserted: [{ content }], messages: [{ content }],
|
|
stream: [{ type: 'chunk', time: 1000, chunk: { type: 'block-end', index: 0, block: content[0] } }],
|
|
} } as SessionEvent
|
|
const stored = log('session-root', undefined, [opaque])
|
|
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
|
|
const readFileStream = vi.fn(async function* (_ref: FileAttachmentRef) { yield new Uint8Array([1]) })
|
|
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage, readFileStream })
|
|
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files)).toEqual([exportLogName])
|
|
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
|
expect(readImage).not.toHaveBeenCalled()
|
|
expect(readFileStream).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('collects media referenced by a flat tool-role result', async () => {
|
|
const callId = 'call' as ToolResultMessage['toolCallId']
|
|
const result: SessionEvent<'tool/result'> = {
|
|
type: 'tool/result', seq: SessionSeq(2), time: 2000, surfaceOp: 'append',
|
|
data: { turn: 1, step: 1, message: {
|
|
id: 'tool-result-message' as ToolResultMessage['id'], role: 'tool', toolCallId: callId, source: { kind: 'tool', callId },
|
|
content: [{ type: 'image', attachment: storedImage('tool-image', 'image/webp').ref }],
|
|
} },
|
|
}
|
|
const api = await buildApi({ 'session-root': log('session-root', undefined, [result]) })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files).sort()).toEqual(['media/tool-image.webp', exportLogName].sort())
|
|
})
|
|
|
|
it('scans wrapped, inserted, and embedded-stream carriers plus non-object content items', async () => {
|
|
const block = (id: string, mediaType: string): unknown =>
|
|
({ type: 'image', attachment: { attachmentId: id, mediaType, bytes: 4, width: 2, height: 2 } })
|
|
const wrapped = {
|
|
type: 'assistant/message', seq: SessionSeq(2), time: 2000,
|
|
data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } },
|
|
} as unknown as SessionEvent
|
|
const inserted = {
|
|
type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 3000,
|
|
data: { target: 'next-turn', start: 0, inserted: [null, 1, [], { content: [] }, {
|
|
id: 'inserted-message' as UserMessage['id'], role: 'user', source: { kind: 'user' }, content: [block('inserted-1', 'image/gif')],
|
|
}] },
|
|
} as SessionEvent
|
|
const attempt = {
|
|
type: 'assistant/attempt', seq: SessionSeq(4), time: 4000,
|
|
data: {
|
|
stream: [
|
|
{ type: 'text-chunks', time0: 3999, index: 0, dt: [], texts: ['ignored'] },
|
|
{ type: 'chunk', time: 4000, chunk: { type: 'finish', reason: { kind: 'stop' } } },
|
|
{
|
|
type: 'chunk', time: 4000,
|
|
chunk: { type: 'block-end', block: block('stream-1', 'image/png') },
|
|
},
|
|
],
|
|
},
|
|
} as unknown as SessionEvent
|
|
const api = await buildApi({ 'session-root': log('session-root', undefined, [wrapped, inserted, attempt]) })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(Object.keys(files).sort()).toEqual([
|
|
'media/inserted-1.gif',
|
|
'media/stream-1.png',
|
|
'media/wrapped-1.jpg',
|
|
exportLogName,
|
|
])
|
|
})
|
|
|
|
it('deduplicates one media object referenced by several included logs', async () => {
|
|
const root = log('session-root', undefined, [imageEvent('shared-img')])
|
|
const child = log('child-a', sid('session-root'), [imageEvent('shared-img')])
|
|
const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
|
)
|
|
const files = unzipSync(await responseBytes(response))
|
|
expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
|
|
expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
|
|
})
|
|
|
|
it('includes descendant media only when descendants are requested', async () => {
|
|
const child = log('child-a', sid('session-root'), [imageEvent('child-img')])
|
|
const api = await buildApi({ 'session-root': log('session-root'), 'child-a': child }, [node('child-a')])
|
|
const without = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual([exportLogName])
|
|
const withDescendants = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
|
)
|
|
expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
|
|
'media/child-img.png',
|
|
exportLogName,
|
|
subagentLogName('child-a'),
|
|
])
|
|
})
|
|
|
|
it('fails the whole export when a referenced image cannot be read', async () => {
|
|
const stored = log('session-root', undefined, [imageEvent('gone-img')])
|
|
const api = await buildApi({ 'session-root': stored }, [], {
|
|
attachments: async () => { throw new Error('attachment bytes missing') },
|
|
})
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(200)
|
|
await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
|
|
})
|
|
|
|
it('answers 500 when the deployment mounts no attachments service', async () => {
|
|
const api = await buildApi({ 'session-root': log('session-root') }, [], { attachments: false })
|
|
const response = await toFetchHandler(api).fetch(
|
|
new Request('http://host/api/session.export?sessionId=session-root'),
|
|
)
|
|
expect(response.status).toBe(500)
|
|
expect(await response.text()).toContain('attachments')
|
|
})
|
|
})
|