fix(session-query): rename session log export package

This commit is contained in:
imccyu
2026-08-13 05:02:00 +08:00
parent 57abe62a83
commit 34dd480ae5
45 changed files with 66 additions and 66 deletions

View File

@@ -0,0 +1,86 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import { apply, inject } from '../src/client/index.ts'
const SID = 'session-export-apply' as SessionId
afterEach(() => { vi.unstubAllGlobals() })
function declare(slots: SlotRegistry): () => void {
return slots.register({
name: 'root',
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
} as never, () => null)
}
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
const declaration = declare(slots)
ctx.provide('locale', new LocaleRuntime(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, declaration, fiber }
}
describe('session-log-download browser plugin', () => {
it('provides one controller and removes its Header contribution on disposal', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })))
const b = await bench()
expect(inject).toEqual(['slots', 'locale'])
expect(b.ctx.sessionLogDownload).toBeDefined()
expect(b.slots.entries('conversation.session.header.actions')).toHaveLength(0)
const entry = b.slots.entries('conversation.session.header.utilities')[0]
expect(entry?.component).toBe(SessionLogDownloadHeaderAction)
expect(entry?.options).toMatchObject({ id: 'session-log-download' })
const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionLogDownloadDialogInjected)()
await injected.request(SID)
expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error')
injected.dismiss(SID)
expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.open).toBe(false)
await b.fiber.dispose()
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
})
it('downloads only for an export execution acknowledged by this browser client', async () => {
const fetcher = vi.fn(async () => new Response('', { status: 500 }))
vi.stubGlobal('fetch', fetcher)
const first = await bench()
const second = await bench()
first.ctx.emit('command/executed', SID, 'plan', { kind: 'success' })
expect(fetcher).not.toHaveBeenCalled()
first.ctx.emit('command/executed', SID, 'export', { kind: 'error', text: 'bad path' })
expect(fetcher).not.toHaveBeenCalled()
first.ctx.emit('command/executed', SID, 'export', { kind: 'success' })
await vi.waitFor(() => {
expect(fetcher).toHaveBeenCalledOnce()
expect(first.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error')
})
expect(second.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]).toBeUndefined()
await first.fiber.dispose()
await second.fiber.dispose()
})
it('re-registers after the declaring Header slot collapses and returns', async () => {
const b = await bench()
b.declaration()
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
const redeclare = declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('conversation.session.header.utilities')[0]?.component).toBe(SessionLogDownloadHeaderAction)
redeclare()
await b.fiber.dispose()
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { CommandDefinition, CommandInvocation } from '@deepseek-ai/dsh-commands'
import * as SessionLogDownload from '../src/index.ts'
describe('/export Web download command', () => {
it('registers one pathless command and removes it with the plugin fiber', async () => {
let descriptor: CommandDefinition | undefined
const ctx = new Context()
ctx.provide('commands', {
register(next: CommandDefinition) {
descriptor = next
return () => { descriptor = undefined }
},
} as never)
const fiber = await ctx.plugin(SessionLogDownload)
expect(descriptor).toMatchObject({
name: 'export',
description: 'Download this Session log as a ZIP archive',
})
const invoke = (rawInput: string) => descriptor?.handler({ rawInput } as CommandInvocation)
await expect(invoke('')).resolves.toEqual({
kind: 'success', text: 'Session log download requested.',
})
await expect(invoke(' output.zip')).resolves.toEqual({
kind: 'error', text: 'The Web /export command does not accept a path.',
})
await fiber.dispose()
expect(descriptor).toBeUndefined()
})
})

View File

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
downloadUrl, SessionLogDownloadController, sessionLogZipFilename,
} from '../src/client/controller.ts'
const SID = 'session-export-controller' as SessionId
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('SessionLogDownloadController', () => {
it('downloads the host ZIP and publishes one shared success state', async () => {
const fetcher = vi.fn(async () => new Response('zip', { status: 200 }))
const save = vi.fn()
const controller = new SessionLogDownloadController(fetcher, save)
await controller.download(SID)
expect(fetcher).toHaveBeenCalledOnce()
const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]
expect(url.pathname).toBe('/api/session.export')
expect(url.searchParams.get('sessionId')).toBe(SID)
expect(url.searchParams.get('includeDescendants')).toBe('true')
expect(init.method).toBe('HEAD')
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(save).toHaveBeenCalledWith(
url.toString(),
'dsh-session-session-export-controller.zip',
)
expect(controller.store.getSnapshot().bySession[SID]).toEqual({
open: true, status: 'success', error: null,
})
})
it('collapses concurrent gestures and preserves a dismissed dialog', async () => {
const response = Promise.withResolvers<Response>()
const fetcher = vi.fn(() => response.promise)
const controller = new SessionLogDownloadController(fetcher, vi.fn())
const first = controller.download(SID)
const second = controller.download(SID)
expect(first).toBe(second)
controller.dismiss(SID)
response.resolve(new Response('zip', { status: 200 }))
await first
expect(fetcher).toHaveBeenCalledOnce()
expect(controller.store.getSnapshot().bySession[SID]?.open).toBe(false)
controller.dismiss(SID)
})
it('publishes HTTP and transport failures without leaking rejections', async () => {
const http = new SessionLogDownloadController(
async () => new Response('backend unavailable', { status: 500 }), vi.fn(),
)
await http.download(SID)
expect(http.store.getSnapshot().bySession[SID]).toEqual({
open: true,
status: 'error',
error: 'Export failed: HTTP 500 backend unavailable',
})
const transport = new SessionLogDownloadController(async () => { throw 'offline' }, vi.fn())
await transport.download(SID)
expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline')
transport.dismiss('absent' as SessionId)
const emptyDetail = new SessionLogDownloadController(
async () => ({
ok: false, status: 503, text: async () => { throw new Error('body unavailable') },
}) as unknown as Response,
vi.fn(),
)
await emptyDetail.download(SID)
expect(emptyDetail.store.getSnapshot().bySession[SID]?.error).toBe('Export failed: HTTP 503')
})
it('aborts active fetches on disposal and rejects later requests', async () => {
let signal: AbortSignal | undefined
const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
signal = init?.signal ?? undefined
signal?.addEventListener('abort', () => {
reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
}, { once: true })
}))
const controller = new SessionLogDownloadController(fetcher, vi.fn())
const pending = controller.download(SID)
await controller.dispose()
await expect(pending).resolves.toBeUndefined()
expect(signal?.aborted).toBe(true)
await expect(controller.download(SID)).resolves.toBeUndefined()
await controller.dispose()
})
it('uses the null-origin fallback and default browser operations', async () => {
vi.stubGlobal('location', { origin: 'null' })
const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip'))
vi.stubGlobal('fetch', fetcher)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const controller = new SessionLogDownloadController()
await controller.download(SID)
expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal')
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' })
expect(click).toHaveBeenCalledOnce()
})
it('defaults dialog openness when state is externally cleared before settlement', async () => {
const success = Promise.withResolvers<Response>()
const successful = new SessionLogDownloadController(() => success.promise, vi.fn())
const successRun = successful.download(SID)
successful.store.set({ bySession: {} })
success.resolve(new Response('zip'))
await successRun
expect(successful.store.getSnapshot().bySession[SID]?.open).toBe(true)
const failure = Promise.withResolvers<Response>()
const failing = new SessionLogDownloadController(() => failure.promise, vi.fn())
const failureRun = failing.download(SID)
failing.store.set({ bySession: {} })
failure.reject(new Error('failed after clear'))
await failureRun
expect(failing.store.getSnapshot().bySession[SID]?.open).toBe(true)
})
})
describe('browser download helpers', () => {
it('sanitizes the archive filename and hands the URL to a download anchor', () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip')
downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip')
expect(click).toHaveBeenCalledOnce()
const anchor = click.mock.instances[0] as HTMLAnchorElement
expect(anchor.href).toBe('http://host/api/session.export?sessionId=a')
expect(anchor.download).toBe('archive.zip')
})
})

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSyncExternalStore } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionLogDownloadController } from '../src/client/controller.ts'
import { SessionLogDownloadDialog } from '../src/client/Dialog.tsx'
import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx'
import { en } from '../src/client/locales.ts'
const SID = 'session-export-dialog' as SessionId
function bench(
controller = new SessionLogDownloadController(
async () => new Response('zip', { status: 200 }), vi.fn(),
),
) {
const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
function useSessionLogDownload<T>(selector: (state: ReturnType<typeof controller.store.getSnapshot>) => T): T {
return useSyncExternalStore(
listener => controller.store.subscribe(listener),
() => selector(controller.store.getSnapshot()),
)
}
const t = (key: keyof typeof en): string => en[key]
const props = { sessionId: SID, useSessionLogDownload, dismiss, t } as unknown as SessionLogDownloadDialogProps
const view = render(<SessionLogDownloadDialog {...props} />)
return { controller, dismiss, view }
}
afterEach(cleanup)
describe('SessionLogDownloadDialog', () => {
it('shows a controller failure and closes it without reading Session history', async () => {
const b = bench()
act(() => {
b.controller.store.set({
bySession: { [SID]: { open: true, status: 'error', error: 'toolbar failed' } },
})
})
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
expect(dialog.textContent).toContain('toolbar failed')
const close = b.view.getAllByRole('button', { name: 'Close' })[0]
if (close === undefined) throw new Error('Session export dialog has no close button')
fireEvent.click(close)
await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) })
})
it('renders the in-flight state and the settled browser download state', async () => {
let release!: (response: Response) => void
const pending = new Promise<Response>((resolve) => { release = resolve })
const controller = new SessionLogDownloadController(() => pending, vi.fn())
const b = bench(controller)
const download = controller.download(SID)
expect(await b.view.findByRole('dialog', { name: 'Exporting Session' })).toBeTruthy()
release(new Response('zip', { status: 200 }))
await download
expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
})
it('uses fallback copy when a failure has no detail', async () => {
const b = bench()
act(() => {
b.controller.store.set({
bySession: { [SID]: { open: true, status: 'error', error: '' } },
})
})
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
expect(dialog.textContent).toContain('Could not start the Session export.')
const close = b.view.getAllByRole('button', { name: 'Close' }).at(-1)
if (close === undefined) throw new Error('Session export dialog has no footer action')
fireEvent.click(close)
await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) })
})
})

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSyncExternalStore } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionLogDownloadController } from '../src/client/controller.ts'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx'
import { en } from '../src/client/locales.ts'
const SID = 'session-export-header' as SessionId
function bindSessionExport(controller: SessionLogDownloadController) {
return function useSessionLogDownload<T>(selector: (state: ReturnType<typeof controller.store.getSnapshot>) => T): T {
return useSyncExternalStore(
listener => controller.store.subscribe(listener),
() => selector(controller.store.getSnapshot()),
)
}
}
function bench() {
const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn())
const request = vi.fn((sessionId: SessionId) => controller.download(sessionId))
const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
const useSessionLogDownload = bindSessionExport(controller)
const props = {
sessionId: SID,
useSessionLogDownload,
request,
dismiss,
t: (key: keyof typeof en): string => en[key],
} as unknown as SessionLogDownloadDialogProps
const view = render(<SessionLogDownloadHeaderAction {...props} />)
return { controller, request, view }
}
afterEach(cleanup)
describe('Session export Header action', () => {
it('renders the 111×32 text capsule and downloads through the shared controller', async () => {
const b = bench()
const button = b.view.getByRole('button', { name: 'Session log' })
expect(button.querySelector('svg')).not.toBeNull()
fireEvent.click(button)
await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) })
expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
})
it('disables the capsule while either entry path downloads this Session', async () => {
const b = bench()
let release!: (response: Response) => void
const pending = new Promise<Response>((resolve) => { release = resolve })
const controller = new SessionLogDownloadController(() => pending, vi.fn())
const useSessionLogDownload = bindSessionExport(controller)
b.view.rerender(<SessionLogDownloadHeaderAction {...({
sessionId: SID,
useSessionLogDownload,
request: (sessionId: SessionId) => controller.download(sessionId),
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
t: (key: keyof typeof en): string => en[key],
} as unknown as SessionLogDownloadDialogProps)} />)
const download = controller.download(SID)
const button = b.view.getByRole('button', { name: 'Session log' })
await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') })
expect((button as HTMLButtonElement).disabled).toBe(true)
release(new Response('zip'))
await download
await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') })
})
})

View File

@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { apply, inject, name } from '../src/invariant.ts'
describe('@deepseek-ai/dsh-session-log-export/invariant', () => {
it('registers the package-owned empty companion', async () => {
const register = vi.fn(() => vi.fn())
const ctx = new Context()
ctx.provide('invariants', { register })
const dispose = await apply(ctx)
expect(name).toBe('session-export-invariant')
expect(inject).toEqual(['invariants'])
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-session-log-export', expect.any(Function))
dispose()
})
})

View File

@@ -0,0 +1,68 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as SessionLogDownload from '@deepseek-ai/dsh-session-log-export'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('session-log-download real Loader composition', () => {
it('discovers and executes /export through the assembled command plane', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-session-export-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-commands'",
"- name: '@deepseek-ai/dsh-session-log-export'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-commands', CommandRuntime],
['@deepseek-ai/dsh-session-log-export', SessionLogDownload],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
const session = (context.get('sessions') as unknown as SessionStore)
.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } })
const agent = { session, status: 'idle', options: {} } as unknown as Agent
expect(context.commands.list(agent)).toContainEqual({
name: 'export', description: 'Download this Session log as a ZIP archive',
})
const execution = await context.commands.execute(agent, '/export', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' })
expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done'])
expect(session.deriveMessages()).toEqual([])
})
})