refactor(agent-presets,web): copy-only preset authoring with a path to the files

The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.

The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.

With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.

New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
Yichen Jiang
2026-08-08 22:35:26 +08:00
parent 2cea99409f
commit b77fb9036c
60 changed files with 2253 additions and 1336 deletions

View File

@@ -27,10 +27,17 @@ usePinnedBrowserLanguages('zh-CN')
const ROSTER_ONE = {
rpcId: 'r',
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
result: {
ok: true as const,
value: {
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
authorable: true,
hasDocument: true,
},
},
}
/** The roster after this browser authored one preset of its own. */
/** The roster after this browser copied one preset of its own. */
const ROSTER_AUTHORED = {
rpcId: 'r',
result: {
@@ -41,6 +48,7 @@ const ROSTER_AUTHORED = {
{ id: 'mine', trust: 'user', isDefault: false },
],
authorable: true,
hasDocument: true,
},
},
}
@@ -56,6 +64,7 @@ const ROSTER_MOVED = {
{ id: 'minimal', trust: 'system', isDefault: true },
],
authorable: true,
hasDocument: true,
},
},
}
@@ -76,15 +85,19 @@ async function bench() {
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
read: () => Promise.resolve({
rpcId: 'r',
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
}),
write: (payload: { agentPreset: string }) => {
calls.push(`write:${payload.agentPreset}`)
copy: (payload: { from: string; agentPreset: string }) => {
calls.push(`copy:${payload.agentPreset}`)
// The host's roster now contains it, which is the whole point of the
// write and what every surface must converge on.
// copy and what every surface must converge on.
ROSTER = ROSTER_AUTHORED
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
},
openDocument: (payload: { agentPreset: string }) => {
calls.push(`openDocument:${payload.agentPreset}`)
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
},
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
select: (payload: { agentPreset: string }) => {
calls.push(`select:${payload.agentPreset}`)
@@ -195,23 +208,29 @@ describe('ui-agent-preset apply', () => {
})
it('routes the section actions to one controller', async () => {
const { ctx, slots } = await bench()
const { ctx, slots, calls } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
section.setId('mine')
section.setContent('- id: x\n')
section.setName('我的模式')
section.setDescription('只做检索。')
section.beginCopy('standard')
section.cancelCopy()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('我的模式')
await section.confirmCopy()
await section.view('standard')
section.closeView()
section.confirmDelete('mine')
section.close()
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
await Promise.all([section.openLocation('mine'), section.remove()])
// One controller behind every action: the delete the section confirmed is
// the one its remove() sees.
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(1)
// One controller behind every action: the copy the dialog named is the
// one the roster re-read reflects, and the delete the section confirmed
// is the one its remove() sees.
expect(calls).toContain('copy:mine')
expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0)
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2)
})
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
@@ -329,14 +348,14 @@ describe('ui-agent-preset apply', () => {
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
await section.createFrom()
section.setId('mine')
section.setName('我的模式')
await section.save()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('我的模式')
await section.confirmCopy()
// Authoring writes a file rather than a setting, so nothing on the wire
// announces it: a preset authored to be used must appear on the one screen
// that starts sessions, without a reload.
// Authoring copies a directory rather than writing a setting, so nothing
// on the wire announces it: a preset created to be used must appear on
// the one screen that starts sessions, without a reload.
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
})

View File

@@ -1,16 +1,17 @@
/**
* The agent-preset management controller: it holds one draft at a time, opens
* a shipped preset read-only, treats "new" as a copy of an existing
* composition, and re-reads the roster after every mutation because a save can
* change more than the row it targeted.
* The agent-preset management controller: a copy dialog is the only way a
* preset is created, the shipped compositions open in a read-only viewer, and
* the way into a custom preset's files is the location action — opened on a
* desktop, revealed as a path where the host has none. Every mutation
* re-reads the roster because a copy changes more than the row it targeted.
*/
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
import type { PresetDraft, PresetRow } from '../src/client/section-store.ts'
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
interface FakePreset { trust: 'system' | 'user'; content: string }
interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
interface Recorded { method: string; payload: unknown }
interface FakeOptions {
@@ -20,16 +21,26 @@ interface FakeOptions {
failList?: string
/** Reject `read` with this message. */
failRead?: string
/** Reject `write` with this message. */
failWrite?: string
/** Reject `copy` with this message. */
failCopy?: string
/** Reject `openDocument` with this message. */
failOpen?: string
/** Reject `remove` with this message. */
failRemove?: string
/** Reject `settings.update` with this message. */
failSettings?: string
/** Throw from `list` rather than answering, as a dead transport does. */
throwList?: boolean
/** Throw from `read`, as a dead transport does. */
throwRead?: boolean
/** Throw from `copy`, as a dead transport does. */
throwCopy?: boolean
/** Throw from `openDocument`, as a dead transport does. */
throwOpen?: boolean
/** Whether the deployment configures a writable root. */
authorable?: boolean
/** Whether the host can open a preset directory on a desktop. */
hasDocument?: boolean
/** Hold `remove` until this resolves, to observe the in-flight state. */
holdRemove?: Promise<void>
}
@@ -39,8 +50,8 @@ const fail = (message: string) =>
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
/**
* A wire face over an in-memory preset store: writes land, so the roster the
* controller re-reads after a save is the one the save produced.
* A wire face over an in-memory preset store: copies land, so the roster the
* controller re-reads after a copy is the one the copy produced.
* @param presets - the starting compositions by id.
* @param defaultId - the preset a session with no choice gets.
* @param options - failure injection and call recording.
@@ -61,12 +72,15 @@ function fakeApi(
return ok({
presets: [...presets].map(([id, preset]) => ({
id, trust: preset.trust, isDefault: id === defaultId.id,
...preset.name === undefined ? {} : { name: preset.name },
})),
authorable: options.authorable ?? true,
hasDocument: options.hasDocument ?? true,
})
},
read: (payload: { agentPreset: string }) => {
record('read', payload)
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
if (options.failRead !== undefined) return fail(options.failRead)
const preset = presets.get(payload.agentPreset)
/* v8 ignore next -- every test reads an id the fake store holds */
@@ -75,15 +89,31 @@ function fakeApi(
agentPreset: payload.agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
...preset.name === undefined ? {} : { name: preset.name },
})
},
write: (payload: { agentPreset: string; content: string }) => {
record('write', payload)
if (options.failWrite !== undefined) return fail(options.failWrite)
presets.set(payload.agentPreset, { trust: 'user', content: payload.content })
copy: (payload: { from: string; agentPreset: string; name?: string }) => {
record('copy', payload)
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
if (options.failCopy !== undefined) return fail(options.failCopy)
const source = presets.get(payload.from)
/* v8 ignore next -- every test copies a source the fake store holds */
if (source === undefined) return fail(`unknown preset ${payload.from}`)
presets.set(payload.agentPreset, {
trust: 'user',
content: source.content,
...payload.name === undefined ? {} : { name: payload.name },
})
return ok({ agentPreset: payload.agentPreset })
},
openDocument: (payload: { agentPreset: string }) => {
record('openDocument', payload)
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
if (options.failOpen !== undefined) return fail(options.failOpen)
return (options.hasDocument ?? true)
? ok({ opened: true })
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
},
remove: async (payload: { agentPreset: string }) => {
record('remove', payload)
await options.holdRemove
@@ -106,7 +136,7 @@ function fakeApi(
function seed(): Map<string, FakePreset> {
return new Map<string, FakePreset>([
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
['mine', { trust: 'user', content: '- id: tool-read\n' }],
])
}
@@ -115,466 +145,436 @@ function harness(options: FakeOptions = {}) {
const presets = seed()
const defaultId = { id: 'standard' }
const calls: Recorded[] = []
let rosterChanges = 0
const controller = new AgentPresetSectionController(
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
() => { rosterChanges += 1 },
)
return { controller, presets, defaultId, calls }
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
}
function draftOf(controller: AgentPresetSectionController): PresetDraft {
const { draft } = controller.store.getSnapshot()
if (draft === null) throw new Error('expected an open draft')
return draft
function copyOf(controller: AgentPresetSectionController): CopyDraft {
const { copy } = controller.store.getSnapshot()
if (copy === null) throw new Error('expected an open copy dialog')
return copy
}
describe('loading the roster', () => {
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
const { controller } = harness()
it('maps the roster onto rows with the capability flags', async () => {
const { controller } = harness({ authorable: true, hasDocument: false })
await controller.load()
const state = controller.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.authorable).toBe(true)
expect(state.rows).toEqual([
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
])
expect(state.hasDocument).toBe(false)
expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
})
it('treats an empty roster as a deployment that composes no presets', async () => {
const presets = new Map<string, FakePreset>()
const controller = new AgentPresetSectionController(fakeApi(presets, { id: '' }, { authorable: false }))
it('reports an empty roster as unavailable, not as an error', async () => {
const { controller, presets } = harness()
presets.clear()
await controller.load()
// Not an error: every session then shares the host composition, and the
// section renders nothing rather than an empty management page.
expect(controller.store.getSnapshot().status).toBe('unavailable')
expect(controller.store.getSnapshot().authorable).toBe(false)
})
it('surfaces a rejected roster call', async () => {
const { controller } = harness({ failList: 'roster unavailable' })
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'roster unavailable' })
})
it('surfaces a transport that rejects rather than answering', async () => {
const { controller } = harness({ throwList: true })
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('ignores a load while one is already in flight', async () => {
it('keeps one load in flight rather than stacking reads', async () => {
const { controller, calls } = harness()
await Promise.all([controller.load(), controller.load()])
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('surfaces a refusal as the page error', async () => {
const { controller } = harness({ failList: 'not for you' })
await controller.load()
const state = controller.store.getSnapshot()
expect(state.status).toBe('error')
expect(state.error).toBe('not for you')
})
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwList: true })
await controller.load()
expect(controller.store.getSnapshot().status).toBe('error')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('opening a composition', () => {
it('opens a locally authored preset for editing', async () => {
describe('the read-only viewer', () => {
it('opens a shipped composition under its display name', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
await controller.view('standard')
expect(draftOf(controller)).toMatchObject({
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
expect(controller.store.getSnapshot().view).toEqual({
id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
})
})
it('opens a shipped preset read-only', async () => {
it('falls back to the id when the preset published no name', async () => {
const { controller } = harness()
await controller.load()
await controller.open('standard')
await controller.view('mine')
// Readable on purpose: it is the known-good composition a local preset is
// written against, and duplicating it is how authoring starts.
expect(draftOf(controller)).toMatchObject({ writable: false, content: '- id: tool-bash\n' })
expect(controller.store.getSnapshot().view?.title).toBe('mine')
})
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
const { controller } = harness({ failRead: 'permission denied' })
it('closes without touching the list', async () => {
const { controller } = harness()
await controller.load()
await controller.view('standard')
controller.closeView()
expect(controller.store.getSnapshot().view).toBeNull()
expect(controller.store.getSnapshot().rows).toHaveLength(2)
})
it('puts a read refusal on the page rather than opening empty', async () => {
const { controller } = harness({ failRead: 'no peeking' })
await controller.load()
await controller.open('mine')
await controller.view('standard')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
expect(controller.store.getSnapshot().view).toBeNull()
expect(controller.store.getSnapshot().error).toBe('no peeking')
})
it('surfaces a transport failure on the page', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, read: () => Promise.reject(new Error('socket closed')) },
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwRead: true })
await controller.load()
await controller.view('standard')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('the copy dialog', () => {
it('opens over the source with its display name in the title', async () => {
const { controller } = harness()
await controller.load()
controller.beginCopy('standard')
expect(copyOf(controller)).toMatchObject({
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
})
await controller.load()
await controller.open('mine')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'socket closed' })
})
it('closes the editor without writing anything', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
controller.setContent('- id: changed\n')
controller.close()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
})
describe('creating a preset', () => {
it('starts blank when no source is named', async () => {
const { controller, calls } = harness()
await controller.load()
const before = calls.length
await controller.createFrom()
// Copying is its own action on the row being copied, so this one is a copy
// of nothing: no source to name, and no read to make.
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, writable: true, content: '' })
expect(draftOf(controller).source).toBeUndefined()
expect(calls).toHaveLength(before)
})
it('copies a named preset', async () => {
it('falls back to the source id when it published no name', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
controller.beginCopy('mine')
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
expect(copyOf(controller).fromTitle).toBe('mine')
})
it('opens the blank editor without the roster, which it no longer reads', async () => {
it('cancel discards whatever was typed', async () => {
const { controller } = harness()
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('half-typed')
controller.cancelCopy()
expect(controller.store.getSnapshot().copy).toBeNull()
})
it('ignores field edits and submits with no dialog open', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
controller.setCopyId('typed-into-nothing')
controller.setCopyName('nameless')
await controller.confirmCopy()
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
expect(calls).toHaveLength(0)
expect(controller.store.getSnapshot().copy).toBeNull()
expect(calls.some(call => call.method === 'copy')).toBe(false)
})
it('typing clears the previous failure', async () => {
const { controller } = harness({ failCopy: 'disk full' })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller).error).toBe('disk full')
controller.setCopyName('renamed')
expect(copyOf(controller).error).toBeNull()
})
})
describe('the save blocker', () => {
const base: PresetDraft = {
id: '', source: 'standard', creating: true, content: '', writable: true,
name: '', description: '', saving: false, error: null,
}
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
describe('the copy blocker', () => {
const rows: PresetRow[] = [
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
]
const draft = (id: string): CopyDraft =>
({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
it('never blocks an edit of an existing preset', () => {
expect(draftBlocker({ ...base, id: 'mine', creating: false }, rows)).toBeUndefined()
})
it('requires a name', () => {
expect(draftBlocker(base, rows)).toBe('idRequired')
})
it.each(['Upper', 'has space', '-leading', 'a/b', '../escape'])('rejects the unusable id %j', (id) => {
// The id becomes a directory name, so the client mirrors the host's own
// containment rule instead of letting the save round-trip to find out.
expect(draftBlocker({ ...base, id }, rows)).toBe('idInvalid')
})
it('rejects a name already in use', () => {
// Replacing is what Edit is for; a create landing on an existing name
// would overwrite a preset the user never opened.
expect(draftBlocker({ ...base, id: 'mine' }, rows)).toBe('idTaken')
})
it('accepts an unused, containable id', () => {
expect(draftBlocker({ ...base, id: 'my-agent2' }, rows)).toBeUndefined()
it('requires an id, a containable shape, and a free name', () => {
expect(draftBlocker(draft(''), rows)).toBe('idRequired')
expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
})
})
describe('saving', () => {
it('creates the preset and re-reads the roster', async () => {
const { controller, presets } = harness()
describe('submitting a copy', () => {
it('copies, re-reads the roster, announces the change, and opens the files', async () => {
const { controller, calls, rosterChanges } = harness()
await controller.load()
await controller.createFrom()
controller.setId('my-agent')
controller.setContent('- id: tool-web-search\n')
controller.beginCopy('standard')
controller.setCopyId('my-copy')
controller.setCopyName('我的模式')
await controller.save()
await controller.confirmCopy()
expect(presets.get('my-agent')).toEqual({ trust: 'user', content: '- id: tool-web-search\n' })
expect(controller.store.getSnapshot().draft).toBeNull()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('my-agent')
const state = controller.store.getSnapshot()
expect(state.copy).toBeNull()
expect(state.rows.map(row => row.id)).toContain('my-copy')
expect(rosterChanges()).toBe(1)
expect(calls.find(call => call.method === 'copy')?.payload)
.toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
// A preset is its files from here on, so landing in them completes the
// copy rather than following it.
expect(calls.find(call => call.method === 'openDocument')?.payload)
.toEqual({ agentPreset: 'my-copy' })
})
it('replaces an existing composition', async () => {
const { controller, presets } = harness()
await controller.load()
await controller.open('mine')
controller.setContent('- id: tool-edit\n')
await controller.save()
expect(presets.get('mine')?.content).toBe('- id: tool-edit\n')
})
it('refuses to write a blocked draft', async () => {
it('omits an empty name so the copy falls back to its id', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
controller.setCopyName(' ')
await controller.save()
await controller.confirmCopy()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(controller.store.getSnapshot().draft).not.toBeNull()
expect(calls.find(call => call.method === 'copy')?.payload)
.toEqual({ from: 'standard', agentPreset: 'my-copy' })
})
it('refuses to write a read-only draft', async () => {
it('reveals the new directory as text where the host has no desktop', async () => {
const { controller } = harness({ hasDocument: false })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
})
it('keeps the dialog open with the refusal on it', async () => {
const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
expect(rosterChanges()).toBe(0)
})
it('folds a dead transport into the dialog error', async () => {
const { controller } = harness({ throwCopy: true })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller).error).toContain('socket closed')
})
it('refuses to submit while blocked or already saving', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('standard')
controller.beginCopy('standard')
controller.setCopyId('mine')
await controller.save()
await controller.confirmCopy()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(calls.some(call => call.method === 'copy')).toBe(false)
})
})
it('does nothing without an open draft', async () => {
describe('the location action', () => {
it('opens the directory and leaves the page alone on a desktop host', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.save()
await controller.openLocation('mine')
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' })
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
})
it('keeps the draft open and reports a rejected save', async () => {
const { controller } = harness({ failWrite: 'composition is not an entry list' })
await controller.load()
await controller.open('mine')
await controller.save()
// The text stays in the editor: it is the only copy, and the message says
// what to fix.
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'composition is not an entry list' })
})
it('reports a transport that rejects mid-save', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, write: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
await controller.open('mine')
await controller.save()
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'socket closed' })
})
it('ignores a second save while one is in flight', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
await Promise.all([controller.save(), controller.save()])
expect(calls.filter(call => call.method === 'write')).toHaveLength(1)
})
it('clears a save failure when the text changes', async () => {
const { controller } = harness({ failWrite: 'invalid' })
await controller.load()
await controller.open('mine')
await controller.save()
controller.setContent('- id: fixed\n')
expect(draftOf(controller).error).toBeNull()
})
it('carries the display name and description through a save', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
controller.setName('我的模式')
controller.setDescription('只做检索。')
await controller.save()
expect(calls.find(call => call.method === 'write')?.payload)
.toMatchObject({ agentPreset: 'mine', name: '我的模式', description: '只做检索。' })
})
it('leaves a copy unnamed so two rows cannot present themselves alike', async () => {
const { controller } = harness()
it('reveals the path on the row where the host has none', async () => {
const { controller } = harness({ hasDocument: false })
await controller.load()
await controller.createFrom('mine')
await controller.openLocation('mine')
// The composition is copied verbatim; the display name is not.
expect(draftOf(controller)).toMatchObject({ id: '', name: '' })
expect(draftOf(controller).content).toBe('- id: tool-read\n')
expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
})
it('ignores an edit with no draft open', () => {
const { controller } = harness()
it('drops a revealed path once its preset leaves the roster', async () => {
const { controller, presets } = harness({ hasDocument: false })
await controller.load()
await controller.openLocation('mine')
presets.delete('mine')
controller.setId('x')
controller.setContent('y')
controller.setName('n')
controller.setDescription('d')
await controller.load()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
})
it('surfaces a refusal as the page error', async () => {
const { controller } = harness({ failOpen: 'not yours' })
await controller.load()
await controller.openLocation('mine')
expect(controller.store.getSnapshot().error).toBe('not yours')
})
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwOpen: true })
await controller.load()
await controller.openLocation('mine')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('deleting', () => {
it('deletes the confirmed preset and re-reads the roster', async () => {
const { controller, presets } = harness()
it('asks first, then deletes, re-reads, and announces the change', async () => {
const { controller, rosterChanges } = harness()
await controller.load()
controller.confirmDelete('mine')
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
await controller.remove()
expect(presets.has('mine')).toBe(false)
expect(controller.store.getSnapshot()).toMatchObject({ pendingDelete: null, deleting: false })
expect(controller.store.getSnapshot().rows.map(row => row.id)).toEqual(['standard'])
})
it('closes an editor open on the deleted preset', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
controller.confirmDelete('mine')
await controller.remove()
// The file is gone; leaving its text in an editor whose Save would
// resurrect it is worse than closing.
expect(controller.store.getSnapshot().draft).toBeNull()
})
it('leaves a copy-in-progress open when its source is deleted', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
controller.setId('mine')
controller.confirmDelete('mine')
await controller.remove()
// The draft is a new preset that happens to be named after the one just
// deleted; its text is unsaved work.
expect(draftOf(controller)).toMatchObject({ id: 'mine', creating: true })
const state = controller.store.getSnapshot()
expect(state.pendingDelete).toBeNull()
expect(state.rows.map(row => row.id)).not.toContain('mine')
expect(rosterChanges()).toBe(1)
})
it('dismisses the confirmation without deleting', async () => {
const { controller, calls, presets } = harness()
await controller.load()
controller.confirmDelete('mine')
controller.confirmDelete(null)
await controller.remove()
expect(calls.some(call => call.method === 'remove')).toBe(false)
expect(presets.has('mine')).toBe(true)
})
it('reports a refused delete on the page', async () => {
const { controller } = harness({ failRemove: 'it ships with the deployment' })
await controller.load()
controller.confirmDelete('standard')
await controller.remove()
expect(controller.store.getSnapshot()).toMatchObject({
pendingDelete: null, deleting: false, error: 'it ships with the deployment',
})
})
it('reports a transport that rejects mid-delete', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, remove: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
controller.confirmDelete('mine')
await controller.remove()
expect(controller.store.getSnapshot()).toMatchObject({ deleting: false, error: 'socket closed' })
})
it('ignores a second delete while one is in flight', async () => {
const { controller, calls } = harness()
await controller.load()
controller.confirmDelete('mine')
await Promise.all([controller.remove(), controller.remove()])
controller.confirmDelete(null)
await controller.remove()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
expect(calls.some(call => call.method === 'remove')).toBe(false)
})
it('ignores a second confirmation while one delete is in flight', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { controller, calls } = harness({ holdRemove: gate })
await controller.load()
controller.confirmDelete('mine')
const removal = controller.remove()
controller.confirmDelete('standard')
await controller.remove()
release()
await removal
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
})
it('ignores a confirmation change while a delete is in flight', async () => {
let release = (): void => {}
const held = new Promise<void>((resolve) => { release = resolve })
const presets = seed()
const controller = new AgentPresetSectionController(
fakeApi(presets, { id: 'standard' }, { holdRemove: held }),
)
it('surfaces a refusal and clears the confirmation', async () => {
const { controller } = harness({ failRemove: 'shipped preset' })
await controller.load()
controller.confirmDelete('mine')
const pending = controller.remove()
// Dismissing mid-flight cannot un-delete the file, so the confirmation
// stays put rather than the page claiming nothing is happening.
controller.confirmDelete(null)
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
release()
await pending
await controller.remove()
expect(presets.has('mine')).toBe(false)
const state = controller.store.getSnapshot()
expect(state.error).toBe('shipped preset')
expect(state.pendingDelete).toBeNull()
expect(state.deleting).toBe(false)
})
it('folds a dead transport into the same error surface', async () => {
const { controller, presets } = harness()
await controller.load()
presets.clear()
const broken = new AgentPresetSectionController({
agentPresets: {
list: () => Promise.reject(new Error('gone')),
remove: () => Promise.reject(new Error('socket closed')),
},
settings: {},
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
broken.confirmDelete('mine')
await broken.remove()
expect(broken.store.getSnapshot().error).toContain('socket closed')
})
})
describe('a controller with no roster listener', () => {
it('completes a delete without anyone to notify', async () => {
// The rosterChanged callback is optional wiring, not a requirement: a
// page composed without sibling surfaces still deletes cleanly.
const presets = seed()
const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
await alone.load()
alone.confirmDelete('mine')
await alone.remove()
expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
})
})
describe('the default preset', () => {
it('writes the settings field and re-reads the roster', async () => {
const { controller, calls, defaultId } = harness()
it('writes the setting and re-reads the roster', async () => {
const { controller, defaultId } = harness()
await controller.load()
await controller.makeDefault('mine')
expect(calls.find(call => call.method === 'settings.update')?.payload)
.toEqual({ ns: 'agent-presets', patch: { default: 'mine' } })
expect(defaultId.id).toBe('mine')
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
})
it('reports a refused write and leaves the roster alone', async () => {
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
it('surfaces a settings refusal as the page error', async () => {
const { controller } = harness({ failSettings: 'read-only settings' })
await controller.load()
await controller.makeDefault('mine')
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
expect(defaultId.id).toBe('standard')
expect(controller.store.getSnapshot().error).toContain('read-only settings')
})
})

View File

@@ -1,9 +1,9 @@
// @vitest-environment jsdom
/**
* The management section's rendering rules: which actions a row offers depends
* on its trust and whether it is the default, a shipped composition opens
* without a Save, and a draft the host would refuse is blocked before it is
* sent.
* on its trust, a shipped composition opens in a read-only viewer, creation is
* a copy dialog that collects an id and an optional name, and the location
* action follows the host's desktop capability.
*/
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@@ -12,7 +12,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionState } from '../src/client/section-store.ts'
import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -21,13 +21,16 @@ const READY: AgentPresetSectionState = {
status: 'ready',
error: null,
authorable: true,
hasDocument: true,
rows: [
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
{ id: 'mine', trust: 'user', isDefault: false },
],
draft: null,
copy: null,
view: null,
pendingDelete: null,
deleting: false,
revealedPaths: {},
}
/**
@@ -39,14 +42,14 @@ function renderSection(state: Partial<AgentPresetSectionState> = {}) {
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
const actions = {
load: vi.fn(() => Promise.resolve()),
open: vi.fn(() => Promise.resolve()),
createFrom: vi.fn(() => Promise.resolve()),
close: vi.fn(),
setId: vi.fn(),
setContent: vi.fn(),
setName: vi.fn(),
setDescription: vi.fn(),
save: vi.fn(() => Promise.resolve()),
view: vi.fn(() => Promise.resolve()),
closeView: vi.fn(),
beginCopy: vi.fn(),
cancelCopy: vi.fn(),
setCopyId: vi.fn(),
setCopyName: vi.fn(),
confirmCopy: vi.fn(() => Promise.resolve()),
openLocation: vi.fn(() => Promise.resolve()),
confirmDelete: vi.fn(),
remove: vi.fn(() => Promise.resolve()),
makeDefault: vi.fn(() => Promise.resolve()),
@@ -105,8 +108,6 @@ describe('the preset list', () => {
// read-only, the other is the user's own.
expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy()
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
expect(within(rowFor('standard')).getByText(en.builtIn)).toBeTruthy()
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
})
it('shows no group heading for a set nobody has', () => {
@@ -115,6 +116,14 @@ describe('the preset list', () => {
expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull()
})
it('leads with the guidance that creation starts from a copy', () => {
renderSection()
// The page has no create button: the intro is what tells a first-time
// reader that duplicating a built-in preset IS the way to make one.
expect(screen.getByText(new RegExp(en.copyHint))).toBeTruthy()
})
it('picks a preset by clicking its card, and the one in use is inert', () => {
const actions = renderSection()
@@ -127,27 +136,49 @@ describe('the preset list', () => {
expect(actions.makeDefault).not.toHaveBeenCalled()
})
it('offers Edit for a local preset and View for a shipped one', () => {
it('offers View on a shipped row and the location on a custom one', () => {
renderSection()
expect(within(rowFor('mine')).getByRole('button', { name: en.edit })).toBeTruthy()
// A shipped composition is readable but not editable, and the label is
// what says so before the editor opens.
expect(within(rowFor('standard')).getByRole('button', { name: en.view })).toBeTruthy()
// A shipped preset is the composition a copy starts from — reading it is
// the point. A custom preset is edited in its files, so its row leads
// there instead; there is no editor for either.
const standard = rowFor('standard')
expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy()
expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull()
const mine = rowFor('mine')
expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy()
expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull()
})
it('offers Delete only for a locally authored preset', () => {
renderSection()
expect(within(rowFor('mine')).getByRole('button', { name: en.delete })).toBeTruthy()
expect(within(rowFor('standard')).queryByRole('button', { name: en.delete })).toBeNull()
expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy()
expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull()
})
it('hides duplication and disables creation when nothing is writable', () => {
it('disables duplication when nothing is writable, and says why', () => {
renderSection({ authorable: false })
expect(screen.queryByRole('button', { name: en.duplicate })).toBeNull()
expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` })
expect(duplicate).toHaveProperty('disabled', true)
expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
})
it('labels the location by what it will do without a desktop', () => {
renderSection({ hasDocument: false })
expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy()
})
it('shows a revealed directory on its row', () => {
renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } })
const mine = rowFor('mine')
expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy()
expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy()
// The reveal belongs to its row alone.
expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull()
})
it('routes the row actions to the controller', () => {
@@ -155,15 +186,14 @@ describe('the preset list', () => {
// The card body is the control that picks a preset.
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.edit }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.duplicate }))
fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` }))
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` }))
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
expect(actions.open).toHaveBeenCalledWith('mine')
expect(actions.createFrom).toHaveBeenCalledWith('mine')
// The bare "new" copies the default, which the controller resolves.
expect(actions.createFrom).toHaveBeenLastCalledWith()
expect(actions.openLocation).toHaveBeenCalledWith('mine')
expect(actions.beginCopy).toHaveBeenCalledWith('mine')
expect(actions.view).toHaveBeenCalledWith('standard')
})
it('shows a page-level failure without hiding the list', () => {
@@ -194,126 +224,97 @@ describe('the preset list', () => {
})
})
describe('the composition editor', () => {
const draft = {
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
writable: true, name: '我的预设', description: '', saving: false, error: null,
describe('the copy dialog', () => {
const draft: CopyDraft = {
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null,
}
it('opens a blank draft without naming a preset it came from', () => {
const { source: _copied, ...blank } = draft
renderSection({ draft: { ...blank, id: '', creating: true } })
it('names its source and collects only an id and a display name', () => {
const actions = renderSection({ copy: draft })
// "New preset" starts empty — copying is what the per-row Duplicate does,
// so a blank draft has no source to name and shows no copied-from hint.
expect(screen.getByText(en.newPreset)).toBeTruthy()
expect(screen.queryByText(new RegExp(en.copyOf))).toBeNull()
const dialog = screen.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`)
expect(within(dialog).getByText(en.copyIntro)).toBeTruthy()
fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } })
fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } })
expect(actions.setCopyId).toHaveBeenCalledWith('my-agent')
expect(actions.setCopyName).toHaveBeenCalledWith('我的模式')
// Nothing else is collected: the description and the composition are
// edited in the preset's own files.
expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull()
})
it('replaces the list while editing, and returns to it', () => {
const actions = renderSection({ draft })
it('creates and cancels through the controller', () => {
const actions = renderSection({ copy: { ...draft, id: 'my-agent' } })
// The form is tall and a card column is ~268px: squeezing it into one is
// unusable, and hanging it off the end orphans it from the card it edits.
expect(screen.queryByRole('heading', { name: en.builtInGroup })).toBeNull()
const editor = screen.getByLabelText(en.composition)
expect(editor).toHaveProperty('value', '- id: tool-read\n')
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
fireEvent.click(screen.getByRole('button', { name: `← ${en.backToList}` }))
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByText(en.create))
fireEvent.click(within(dialog).getByText(en.cancel))
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
expect(actions.close).toHaveBeenCalledTimes(1)
expect(actions.confirmCopy).toHaveBeenCalledTimes(1)
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
})
it('saves and cancels through the controller', () => {
const actions = renderSection({ draft })
it('blocks a copy the host would refuse, and says why', () => {
const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } })
fireEvent.click(screen.getByText(en.save))
fireEvent.click(screen.getByText(en.cancel))
expect(actions.save).toHaveBeenCalledTimes(1)
expect(actions.close).toHaveBeenCalledTimes(1)
})
it('reports a save in flight and blocks a second click', () => {
const actions = renderSection({ draft: { ...draft, saving: true } })
fireEvent.click(screen.getByText(en.saving))
expect(actions.save).not.toHaveBeenCalled()
})
it('shows a shipped composition read-only, with no way to save it', () => {
renderSection({ draft: { ...draft, id: 'standard', source: 'standard', writable: false } })
expect(screen.getByLabelText(en.composition)).toHaveProperty('readOnly', true)
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
expect(screen.queryByText(en.save)).toBeNull()
// Nothing to commit or abandon, and the back link above already leaves —
// a lone Close button would be a second way out of the same screen.
expect(screen.queryByText(en.cancel)).toBeNull()
expect(screen.getByRole('button', { name: `← ${en.backToList}` })).toBeTruthy()
})
it('titles the panel by what it is doing', () => {
renderSection({ draft })
expect(screen.getByText(`${en.edit} · 我的预设`)).toBeTruthy()
cleanup()
// An unnamed draft falls back to what it was copied from.
renderSection({ draft: { ...draft, name: '' } })
expect(screen.getByText(`${en.edit} · mine`)).toBeTruthy()
cleanup()
renderSection({ draft: { ...draft, writable: false } })
expect(screen.getByText(`${en.view} · 我的预设`)).toBeTruthy()
})
it('names a new preset and says what it was copied from', () => {
const actions = renderSection({
draft: { ...draft, id: '', source: 'standard', creating: true },
})
expect(screen.getByText(`${en.copyOf} standard`)).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } })
expect(actions.setId).toHaveBeenCalledWith('my-agent')
})
it('edits the display name and description through the controller', () => {
const actions = renderSection({ draft })
fireEvent.change(screen.getByLabelText(en.displayName), { target: { value: '我的模式' } })
fireEvent.change(screen.getByLabelText(en.displayDescription), { target: { value: '只做检索。' } })
expect(actions.setName).toHaveBeenCalledWith('我的模式')
expect(actions.setDescription).toHaveBeenCalledWith('只做检索。')
})
it('offers no display fields on a read-only preset', () => {
renderSection({ draft: { ...draft, writable: false } })
// Nothing here can be saved, so an editable name would be a lie.
expect(screen.queryByLabelText(en.displayName)).toBeNull()
})
it('blocks a save the host would refuse, and says why', () => {
const actions = renderSection({
draft: { ...draft, id: 'Upper Case', source: 'standard', creating: true },
})
expect(screen.getByRole('alert').textContent).toBe(en.idInvalid)
fireEvent.click(screen.getByText(en.save))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid)
fireEvent.click(within(dialog).getByText(en.create))
// Disabled rather than round-tripping: the id is a directory name and the
// rule is the host's own.
expect(actions.save).not.toHaveBeenCalled()
expect(actions.confirmCopy).not.toHaveBeenCalled()
})
it('shows the host\'s refusal instead of the local blocker', () => {
renderSection({ draft: { ...draft, error: 'composition is not an entry list' } })
renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } })
expect(screen.getByRole('alert').textContent).toBe('composition is not an entry list')
expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists')
})
it('reports a copy in flight and blocks a second click', () => {
const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } })
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating))
expect(actions.confirmCopy).not.toHaveBeenCalled()
})
it('dismisses on Escape', () => {
const actions = renderSection({ copy: draft })
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
})
})
describe('the read-only viewer', () => {
it('shows the composition text under the preset\'s name', () => {
renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } })
const dialog = screen.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`)
expect(within(dialog).getByText(en.composition)).toBeTruthy()
expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n')
})
it('closes through the controller', () => {
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close))
expect(actions.closeView).toHaveBeenCalledTimes(1)
})
it('dismisses on Escape', () => {
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.closeView).toHaveBeenCalledTimes(1)
})
})
@@ -321,7 +322,7 @@ describe('deleting a preset', () => {
it('asks before deleting', () => {
const actions = renderSection()
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.delete }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` }))
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
})