Files
deepseek-harness/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
T
Turtle 585d20fc77 fix(ui-chat): pause the running clock while reconnecting
The Chat running indicator kept counting while the Client connection was
disconnected or reconnecting, which read as the agent still working. The
flow now reads the connection state and holds the clock until the
connection is back.
2026-10-15 17:47:02 +08:00

494 lines
24 KiB
TypeScript

import { MessageId } from '@deepseek-ai/dsh-llm/brand'
// @vitest-environment jsdom
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import {
SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import {
apply as applyConversation, inject as injectConversation,
type GroupKey,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
apply as applyChat, inject as injectChat, type ChatFlowDataInjected, type ChatViewInjected,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { createChatStore } from '../src/client/stores.ts'
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx'
import type { TranscriptViewRowInjected } from '../src/client/settings/TranscriptViewRow.tsx'
usePinnedBrowserLanguages('zh-CN')
const ROOT = 'root-1' as SessionId
const ATTACHMENT = {
attachmentId: AttachmentId('image-1'),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
} as const
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
function sessionFakeFor() {
return {
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
loadThrough: vi.fn<ISession['loadThrough']>(() => Promise.resolve()),
readAttachment: vi.fn<ISession['readAttachment']>(() => Promise.resolve({
ok: true,
value: { attachment: ATTACHMENT, data: Uint8Array.of(1) },
})),
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
} satisfies SessionBehaviorOverrides
}
async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, withProcessGroups = true) {
const runtime = await SlotTestRuntime.create()
const chatSettings = stubConfigForm<ChatSettings>()
if (initialSettings !== undefined) chatSettings.publish({ value: initialSettings })
runtime.ctx.provide('configForms', {
developerTools: { enabled: createSnapshotStore(true) },
get: (id: string) => id === CHAT_SETTINGS_NAMESPACE ? chatSettings.scope : stubConfigForm().scope,
} as never)
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', layout as never)
const sidebarRight = {
openResource: vi.fn<(address: string) => void>(),
openTab: vi.fn<(kind: string, options?: unknown) => void>(),
}
runtime.ctx.provide('sidebarRight', sidebarRight as never)
const browserAvailable = createSnapshotStore(true)
const sidebarRightTabs = {
get: vi.fn<(kind: string) => object | undefined>(() => browserAvailable.getSnapshot() ? {} : undefined),
subscribe: (listener: () => void) => browserAvailable.subscribe(listener),
register: vi.fn(() => () => {}),
}
if (withBrowserRegistry) runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never)
runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never)
const openWorkspacePath = vi.fn<ClientRemote['session']['openWorkspacePath']>(
() => Promise.resolve({ ok: true, value: { opened: true } }),
)
runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
const openSession = vi.fn<(id: SessionId) => void>()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(ROOT)
openSession(ROOT)
}),
openSession,
} as never)
const session = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
session,
})
const rootReference = runtime.sessions.retain(ROOT)
await rootReference.ready
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({
'main': { kind: 'keyed', scope: 'root' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
const registerGroups = withProcessGroups ? undefined
: vi.spyOn(runtime.ctx.uiConversation.groups, 'register').mockImplementation(() => () => {})
const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat })
registerGroups?.mockRestore()
runtime.renderRoot()
const chatViewApi = (reference: SessionReference) => {
const id = reference.sessionId
const entry = runtime.slots.entries('conversation.view')[0]!
const instance = runtime.storeOf('conversation.view', reference) as ChatInstance
const injected = (entry.inject as unknown as (
sessionId: SessionId,
actions: ChatActions,
) => ChatViewInjected)(id, instance.actions)
return { instance, injected }
}
const chatFlowApi = (reference: SessionReference) => {
const entry = runtime.slots.entries('conversation.chat.flow')[0]!
const instance = runtime.storeOf('conversation.chat.flow', reference) as ChatInstance
const injectFlow = entry.inject as (sessionId: SessionId) => ChatFlowDataInjected
return { instance, injected: injectFlow(reference.sessionId) }
}
return {
get linkPreference() {
const row = runtime.slots.entries('settings.general.item').find(entry => entry.options.id === 'link-opening')!
const preference: object = row.inject!()
return preference as LinkOpeningRowInjected
},
runtime, chat, chatSettings, browserAvailable,
layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, chatFlowApi, rootReference, openSession,
}
}
describe('Chat inject API', () => {
it('projects independent Settings choices and saves timing to Host settings', async () => {
const b = await bench()
try {
const row = b.runtime.slots.entries('settings.general.item')
.find(entry => entry.options.id === 'transcript-view')!
const preference = row.inject!()
const setCollapseTiming = preference.setCollapseTiming as TranscriptViewRowInjected['setCollapseTiming']
const { injected } = b.chatViewApi(b.rootReference)
const presentation = injected.hooks.presentation
expect(presentation.getSnapshot()).toMatchObject({ mode: 'detailed', collapseTiming: 'completion' })
setCollapseTiming('next-input')
expect(presentation.getSnapshot()).toMatchObject({ mode: 'detailed', collapseTiming: 'next-input' })
expect(b.chatSettings.set).toHaveBeenCalledWith('collapseTiming', 'next-input')
b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true })
expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'next-input' })
setCollapseTiming('completion')
expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'completion' })
expect(b.chatSettings.set).toHaveBeenLastCalledWith('collapseTiming', 'completion')
const setFoldedReplies = preference.setFoldedReplies as TranscriptViewRowInjected['setFoldedReplies']
expect(presentation.getSnapshot()).toMatchObject({ foldedReplies: 'all' })
setFoldedReplies('final')
expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'completion', foldedReplies: 'final' })
expect(b.chatSettings.set).toHaveBeenLastCalledWith('foldedReplies', 'final')
expect(b.session.prompt).not.toHaveBeenCalled()
expect(b.session.cancel).not.toHaveBeenCalled()
} finally {
await b.runtime.dispose()
}
})
it('resolves keyed Group sources across registration, activation, and removal', async () => {
const b = await bench(undefined, true, false)
try {
const { injected } = b.chatFlowApi(b.rootReference)
const key = 'injected-group' as GroupKey
expect(injected.keyedHooks.chatGroup(key)).toBeUndefined()
const conversation = b.runtime.ctx.uiConversation
const remove = conversation.groups.register({
kind: 'test-group', target: 'chat',
create: () => null,
update: () => null,
buildGroups: () => ({
entries: [{ kind: 'group', key }],
groups: { kind: 'replace', snapshots: [{
key, data: { turn: 1, closed: true, summary: { counts: [], running: undefined, runningDetail: '' } }, members: [],
}] },
}),
})
await Promise.resolve()
conversation.binding(b.rootReference.binding).activate('chat')
const source = injected.keyedHooks.chatGroup(key)
expect(source?.getSnapshot()?.data.turn).toBe(1)
expect(injected.keyedHooks.chatGroup(key)).toBe(source)
remove()
await Promise.resolve()
expect(source?.getSnapshot()).toBeUndefined()
expect(injected.keyedHooks.chatGroup(key)).toBeUndefined()
} finally {
await b.runtime.dispose()
}
})
it('loads older history and forks through the Session Controller', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
injected.loadOlder()
expect(b.session.loadOlder).toHaveBeenCalledOnce()
void injected.loadThrough(SessionSeq(42))
expect(b.session.loadThrough).toHaveBeenCalledWith(42)
const track = vi.fn()
b.runtime.ctx.provide('productAnalytics', { track } as never)
const forkCreated = vi.spyOn(b.runtime.sessions, 'fork').mockImplementation(async (input) => { input.onCreated?.(ROOT); return ROOT })
injected.forkAt(17)
expect(track).toHaveBeenCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, click_position: 'footer' })
await b.runtime.sessions.replaceEvents(ROOT, [
{ type: 'event', event: { type: 'turn/start', seq: SessionSeq(10), time: 10, data: { turn: 1 } } },
{ type: 'event', event: { type: 'step/start', seq: SessionSeq(11), time: 11, data: { turn: 1, step: 1 } } },
{ type: 'event', event: { type: 'assistant/message', seq: SessionSeq(12), time: 12, surfaceOp: 'append', data: {
turn: 1, step: 1, stream: [], message: { id: MessageId('reply'), role: 'assistant', content: [{ type: 'text', text: 'answer' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } },
} } },
{ type: 'event', event: { type: 'turn/end', seq: SessionSeq(17), time: 17, data: { turn: 1, reason: { kind: 'completed' } } } },
])
injected.forkAt(17)
expect(track).toHaveBeenLastCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, parent_message_id: 'reply', click_position: 'footer' })
forkCreated.mockRestore()
injected.forkAt(17)
await vi.waitFor(() => {
expect(b.openSession).toHaveBeenCalledWith(ROOT)
})
expect(b.runtime.sessions.calls).toContainEqual({
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void }],
})
const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed'))
injected.forkAt(18)
await vi.waitFor(() => {
expect(fork).toHaveBeenCalledWith({
sessionId: ROOT, atSeq: 18, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void,
})
})
await b.runtime.dispose()
})
it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
await injected.openFile('src/a.ts')
// Files stay in the product: a relative path is handed to the Sidebar as an
// address under this session's scope, not to a desktop opener.
expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-1/src/a.ts')
expect(b.openWorkspacePath).not.toHaveBeenCalled()
// An absolute path inside the session's workspace is the same session-relative address.
await injected.openFile('/proj/src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts')
// A name a URL would otherwise mangle survives the round trip.
await injected.openFile('src/a b#c.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a%20b%23c.ts')
// A line travels as the `file` type's navigation parameter, not in the address.
await injected.openFile('src/a.ts', { line: 7 })
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts', { params: { line: 7 } })
await b.runtime.dispose()
})
it('resolves relative file paths against the Session\'s current working directory', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
// A leading `./` names the same file, so the Sidebar reuses its tab.
await injected.openFile('./src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
await injected.openFile('/proj/src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts')
// Outside the workspace, the address carries the absolute path after the Session id.
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/elsewhere')
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1//elsewhere/src/a.ts')
await b.runtime.dispose()
})
it('opens message HTTP(S) links in Sidebar Browser tabs', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
injected.openExternalLink('http://example.test/path')
injected.openExternalLink('https://example.test/path')
expect(b.sidebarRight.openTab.mock.calls).toEqual([
['browser', { params: { url: 'http://example.test/path' } }],
['browser', { params: { url: 'https://example.test/path' } }],
])
await b.runtime.dispose()
})
it('opens message HTTP(S) links in the system browser when no Sidebar Browser is registered', async () => {
const b = await bench()
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
try {
b.sidebarRightTabs.get.mockReturnValue(undefined)
const { injected } = b.chatViewApi(b.rootReference)
injected.openExternalLink('https://example.test/path')
expect(b.sidebarRight.openTab).not.toHaveBeenCalled()
expect(open).toHaveBeenCalledWith('https://example.test/path', '_blank', 'noopener,noreferrer')
} finally {
open.mockRestore()
await b.runtime.dispose()
}
})
it('applies restored and live link destinations without remounting the Chat view', async () => {
const settings: ChatSettings = { transcriptView: 'compact', performanceUsage: 'detailed', linkOpening: 'new-tab' }
const b = await bench(settings)
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
try {
const { injected } = b.chatViewApi(b.rootReference)
const source = b.linkPreference.hooks.linkOpening
expect(source.getSnapshot()).toBe('new-tab')
injected.openExternalLink('https://example.test/restored')
expect(open).toHaveBeenLastCalledWith('https://example.test/restored', '_blank', 'noopener,noreferrer')
expect(b.sidebarRight.openTab).not.toHaveBeenCalled()
b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } })
expect(source.getSnapshot()).toBe('sidebar')
injected.openExternalLink('http://example.test/live')
expect(b.sidebarRight.openTab).toHaveBeenLastCalledWith('browser', { params: { url: 'http://example.test/live' } })
b.linkPreference.setLinkOpening('new-tab')
expect(b.chatSettings.set).toHaveBeenCalledWith('linkOpening', 'new-tab')
injected.openExternalLink('http://example.test/selected')
expect(open).toHaveBeenLastCalledWith('http://example.test/selected', '_blank', 'noopener,noreferrer')
await b.chat.dispose()
expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false)
b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } })
expect(source.getSnapshot()).toBe('new-tab')
} finally {
open.mockRestore()
await b.runtime.dispose()
}
})
it('follows browser registration and routes absent browsers externally', async () => {
const b = await bench()
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const available = b.linkPreference.hooks.browserAvailable
const changed = vi.fn()
const unsubscribe = available.subscribe(changed)
try {
const { injected } = b.chatViewApi(b.rootReference)
expect(available.getSnapshot()).toBe(true)
b.browserAvailable.set(false)
expect(available.getSnapshot()).toBe(false)
expect(changed).toHaveBeenCalledOnce()
injected.openExternalLink('https://example.test/disabled')
expect(open).toHaveBeenCalledWith('https://example.test/disabled', '_blank', 'noopener,noreferrer')
b.browserAvailable.set(true)
injected.openExternalLink('https://example.test/enabled')
expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/enabled' } })
expect(b.linkPreference.hooks.linkOpening.getSnapshot()).toBe('sidebar')
} finally {
unsubscribe()
open.mockRestore()
await b.runtime.dispose()
}
})
it('keeps Chat available while the optional tab registry appears and leaves', async () => {
const b = await bench(undefined, false)
try {
expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1)
expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false)
const registry = await b.runtime.mount({
apply(ctx) { ctx.provide('sidebarRightTabs', b.sidebarRightTabs as never) },
})
await vi.waitFor(() => {
expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(true)
})
await registry.dispose()
expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false)
expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1)
} finally {
await b.runtime.dispose()
}
})
it('keeps local link choices usable when settings cannot persist', async () => {
const b = await bench()
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
try {
const { injected } = b.chatViewApi(b.rootReference)
b.chatSettings.publish({ status: 'unavailable' })
b.chatSettings.set.mockResolvedValueOnce(false)
b.linkPreference.setLinkOpening('new-tab')
b.chatSettings.publish({ value: undefined })
injected.openExternalLink('https://example.test/local')
expect(open).toHaveBeenCalledWith('https://example.test/local', '_blank', 'noopener,noreferrer')
expect(b.sidebarRight.openTab).not.toHaveBeenCalled()
b.chatSettings.set.mockRejectedValueOnce(new Error('settings unavailable'))
b.linkPreference.setLinkOpening('sidebar')
await Promise.resolve()
injected.openExternalLink('https://example.test/local-sidebar')
expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/local-sidebar' } })
} finally {
open.mockRestore()
await b.runtime.dispose()
}
})
it('leaves skill preview injection to the skill reference owner', async () => {
const b = await bench()
try {
const { injected } = b.chatViewApi(b.rootReference)
expect(injected).not.toHaveProperty('openSkill')
} finally {
await b.runtime.dispose()
}
})
it('keeps a relative path under the Session without a cwd, and addresses a path outside the workspace absolutely', async () => {
const b = await bench()
const NO_CWD = 'root-2' as SessionId
await b.runtime.sessions.add({
id: NO_CWD,
summary: { title: 'N', displayTitle: 'N' },
session: sessionFakeFor(),
})
using reference = b.runtime.sessions.retain(NO_CWD)
const { injected } = b.chatViewApi(reference)
// The Host resolves the relative path against the root it holds for the
// Session; the Client need not know it.
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-2/src/a.ts')
// An absolute path outside every known root still names its Session.
await injected.openFile('/abs/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-2//abs/a.ts')
await b.runtime.dispose()
})
it('fails loud when a Chat View inject resolves no Session', async () => {
const b = await bench()
const entry = b.runtime.slots.entries('conversation.view')[0]!
const injectView = entry.inject as unknown as (
sessionId: SessionId,
actions: ChatActions,
) => ChatViewInjected
expect(() => injectView('never-listed' as SessionId, {} as ChatActions))
.toThrow(/unknown session/)
const flowEntry = b.runtime.slots.entries('conversation.chat.flow')[0]!
const injectFlow = flowEntry.inject as (sessionId: SessionId) => ChatFlowDataInjected
expect(() => injectFlow('never-listed' as SessionId)).toThrow(/unknown session/)
await b.runtime.dispose()
})
it('owns image loading, scroll memory, and optional closing-file mentions', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
const owner = {} as never
const flow = b.chatFlowApi(b.rootReference)
expect(flow.instance).toBe(b.chatViewApi(b.rootReference).instance)
expect(flow.injected.keyedHooks.chatNode('missing')).toBe(injected.keyedHooks.chatNode('missing'))
expect(flow.injected.keyedHooks.chatNodeProcess('missing')).toBe(injected.keyedHooks.chatNodeProcess('missing'))
expect(flow.injected.keyedHooks.chatNodeBottom('missing')).toBe(injected.keyedHooks.chatNodeBottom('missing'))
expect(flow.injected.hooks.connectionState).toBe(b.runtime.ctx.get('connection')!.state)
expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation)
expect(injected.fileMentions(owner)).toBeUndefined()
const mentions = { resolve: vi.fn() } as never
const forClosing = vi.fn(() => mentions)
b.runtime.ctx.provide('chatFileMentions', { forClosing } as never)
expect(injected.fileMentions(owner)).toBe(mentions)
expect(forClosing).toHaveBeenCalledWith(owner, ROOT)
expect(injected.chatScroll.read()).toBeNull()
const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 }
injected.chatScroll.save(position)
expect(injected.chatScroll.read()).toEqual(position)
injected.chatScroll.save(null)
expect(injected.chatScroll.read()).toBeNull()
const loaded = await injected.loadImage(ATTACHMENT)
expect(loaded).toEqual(expect.any(String))
expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId)
expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded)
await b.runtime.dispose()
})
})