section-store.client.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. /**
  2. * The agent-preset management controller: a copy dialog is the only way a
  3. * preset is created, the shipped compositions open in a read-only viewer, and
  4. * the way into a custom preset's files is the location action — opened on a
  5. * desktop, revealed as a path where the host has none. Every mutation
  6. * re-reads the roster because a copy changes more than the row it targeted.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import type { Context as ClientContext } from '@deepseek-ai/cordis'
  10. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  11. import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
  12. import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
  13. interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
  14. interface Recorded { method: string; payload: unknown }
  15. interface FakeOptions {
  16. /** Every call the controller made, in order. */
  17. calls?: Recorded[]
  18. /** Reject `list` with this message. */
  19. failList?: string
  20. /** Reject only this numbered `list` call. */
  21. failListAt?: number
  22. /** Reject `read` with this message. */
  23. failRead?: string
  24. /** Reject `copy` with this message. */
  25. failCopy?: string
  26. /** Reject `openDocument` with this message. */
  27. failOpen?: string
  28. /** Reject `remove` with this message. */
  29. failRemove?: string
  30. /** Reject `settings.update` with this message. */
  31. failSettings?: string
  32. /** Whether the deployment configures a writable root. */
  33. authorable?: boolean
  34. /** Whether the host can open a preset directory on a desktop. */
  35. hasDocument?: boolean
  36. /** Refuse the opener capability read. */
  37. failCapability?: string
  38. /** Hold `remove` until this resolves, to observe the in-flight state. */
  39. holdRemove?: Promise<void>
  40. /** Initial new-session picker visibility. */
  41. showPicker?: boolean
  42. /** Mutable Host policy used when a Settings write is reflected by the roster. */
  43. pickerPolicy?: { enabled: boolean }
  44. /** Simulate a concurrent Host write winning after this client's policy write. */
  45. ignorePickerWrite?: boolean
  46. /** Throw from the Settings transport instead of returning a Remote failure. */
  47. throwSettings?: unknown
  48. }
  49. const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
  50. const remoteFail = (message: string) =>
  51. Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', message, {}) })
  52. /**
  53. * The Remote namespace over an in-memory preset store: copies land, so the
  54. * roster the controller re-reads after a copy is the one the copy produced.
  55. * @param presets - the starting compositions by id.
  56. * @param defaultId - the preset a session with no choice gets.
  57. * @param options - failure injection and call recording.
  58. * @returns the fake plugin context carrying the Remote namespaces.
  59. */
  60. function fakeCtx(
  61. presets: Map<string, FakePreset>,
  62. defaultId: { id: string },
  63. options: FakeOptions = {},
  64. ): ClientContext {
  65. const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
  66. let listCount = 0
  67. return {
  68. remote: {
  69. agentPresets: {
  70. list: () => {
  71. record('list', {})
  72. listCount += 1
  73. if (options.failList !== undefined
  74. && (options.failListAt === undefined || options.failListAt === listCount)) {
  75. return remoteFail(options.failList)
  76. }
  77. const selectionEnabled = options.pickerPolicy?.enabled ?? options.showPicker ?? true
  78. const effectiveDefault = selectionEnabled ? defaultId.id : 'standard'
  79. return remoteOk({
  80. presets: [...presets].map(([id, preset]) => ({
  81. id, trust: preset.trust, isDefault: id === effectiveDefault,
  82. ...preset.name === undefined ? {} : { name: preset.name },
  83. })),
  84. authorable: options.authorable ?? true,
  85. modeSelectionEnabled: selectionEnabled,
  86. })
  87. },
  88. read: (agentPreset: string) => {
  89. record('read', { agentPreset })
  90. if (options.failRead !== undefined) return remoteFail(options.failRead)
  91. const preset = presets.get(agentPreset)
  92. /* v8 ignore next -- every test reads an id the fake store holds */
  93. if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`)
  94. return remoteOk({
  95. agentPreset,
  96. trust: preset.trust,
  97. content: preset.content,
  98. ...preset.name === undefined ? {} : { name: preset.name },
  99. })
  100. },
  101. // Arity is checked against the declaration, not against which arguments
  102. // carry a value, so a short call rejects instead of answering. Reject
  103. // one here too: the real face would, and a lenient double hid it once.
  104. copy: (...args: [from: string, id: string, name?: string]) => {
  105. if (args.length !== 3) {
  106. return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`))
  107. }
  108. const [from, id, name] = args
  109. record('copy', { from, id, ...name === undefined ? {} : { name } })
  110. if (options.failCopy !== undefined) return remoteFail(options.failCopy)
  111. const source = presets.get(from)
  112. /* v8 ignore next -- every test copies a source the fake store holds */
  113. if (source === undefined) return remoteFail(`unknown preset ${from}`)
  114. presets.set(id, {
  115. trust: 'user',
  116. content: source.content,
  117. ...name === undefined ? {} : { name },
  118. })
  119. return remoteOk(undefined)
  120. },
  121. deletePreset: async (id: string) => {
  122. record('deletePreset', { id })
  123. await options.holdRemove
  124. if (options.failRemove !== undefined) return await remoteFail(options.failRemove)
  125. presets.delete(id)
  126. return await remoteOk(undefined)
  127. },
  128. },
  129. settings: {
  130. update: (ns: string, patch: { default?: unknown; modeSelectionEnabled?: unknown }) => {
  131. record('settings.update', { ns, patch })
  132. if (options.throwSettings !== undefined) throw options.throwSettings
  133. if (options.failSettings !== undefined) return remoteFail(options.failSettings)
  134. if (typeof patch.default === 'string') defaultId.id = patch.default
  135. if (typeof patch.modeSelectionEnabled === 'boolean' && !options.ignorePickerWrite) {
  136. const policy = options.pickerPolicy ?? { enabled: options.showPicker ?? true }
  137. policy.enabled = patch.modeSelectionEnabled
  138. }
  139. return remoteOk({})
  140. },
  141. canOpenAgentPresetDirectory: () => {
  142. record('canOpenAgentPresetDirectory', {})
  143. return options.failCapability === undefined
  144. ? remoteOk(options.hasDocument ?? true)
  145. : remoteFail(options.failCapability)
  146. },
  147. openAgentPresetDirectory: (agentPreset: string) => {
  148. record('openAgentPresetDirectory', { agentPreset })
  149. if (options.failOpen !== undefined) return remoteFail(options.failOpen)
  150. return (options.hasDocument ?? true)
  151. ? remoteOk({ opened: true })
  152. : remoteOk({ opened: false, path: `/presets/${agentPreset}` })
  153. },
  154. },
  155. },
  156. } as unknown as ClientContext
  157. }
  158. function seed(): Map<string, FakePreset> {
  159. return new Map<string, FakePreset>([
  160. ['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
  161. ['mine', { trust: 'user', content: '- id: tool-read\n' }],
  162. ])
  163. }
  164. function harness(options: FakeOptions = {}) {
  165. const presets = seed()
  166. const defaultId = { id: 'standard' }
  167. const calls: Recorded[] = []
  168. let rosterChanges = 0
  169. const pickerPolicy = options.pickerPolicy ?? { enabled: options.showPicker ?? true }
  170. const wired = { ...options, pickerPolicy, calls: options.calls ?? calls }
  171. const controller = new AgentPresetSectionController(
  172. fakeCtx(presets, defaultId, wired),
  173. () => { rosterChanges += 1 },
  174. )
  175. return {
  176. controller, presets, defaultId, pickerPolicy, calls,
  177. rosterChanges: () => rosterChanges,
  178. }
  179. }
  180. function copyOf(controller: AgentPresetSectionController): CopyDraft {
  181. const { copy } = controller.store.getSnapshot()
  182. if (copy === null) throw new Error('expected an open copy dialog')
  183. return copy
  184. }
  185. describe('loading the roster', () => {
  186. it('still lists the roster when the opener capability is refused', async () => {
  187. const { controller } = harness({ failCapability: 'no opener here' })
  188. await controller.load()
  189. // The two reads are independent: a refused capability query costs the
  190. // open-directory affordance, not the page.
  191. const state = controller.store.getSnapshot()
  192. expect(state.status).toBe('ready')
  193. expect(state.hasDocument).toBe(false)
  194. expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
  195. })
  196. it('maps the roster onto rows with the capability flags', async () => {
  197. const { controller } = harness({ authorable: true, hasDocument: false })
  198. await controller.load()
  199. const state = controller.store.getSnapshot()
  200. expect(state.status).toBe('ready')
  201. expect(state.authorable).toBe(true)
  202. expect(state.hasDocument).toBe(false)
  203. expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
  204. expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
  205. })
  206. it('reports an empty roster as unavailable, not as an error', async () => {
  207. const { controller, presets } = harness()
  208. presets.clear()
  209. await controller.load()
  210. expect(controller.store.getSnapshot().status).toBe('unavailable')
  211. })
  212. it('coalesces concurrent refreshes into one follow-up read', async () => {
  213. const { controller, calls } = harness()
  214. await Promise.all([controller.load(), controller.load(), controller.load()])
  215. expect(calls.filter(call => call.method === 'list')).toHaveLength(2)
  216. })
  217. it('surfaces a refusal as the page error', async () => {
  218. const { controller } = harness({ failList: 'not for you' })
  219. await controller.load()
  220. const state = controller.store.getSnapshot()
  221. expect(state.status).toBe('error')
  222. expect(state.error).toBe('not for you')
  223. })
  224. })
  225. describe('the read-only viewer', () => {
  226. it('opens a shipped composition under its display name', async () => {
  227. const { controller } = harness()
  228. await controller.load()
  229. await controller.view('standard')
  230. expect(controller.store.getSnapshot().view).toEqual({
  231. id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
  232. })
  233. })
  234. it('falls back to the id when the preset published no name', async () => {
  235. const { controller } = harness()
  236. await controller.load()
  237. await controller.view('mine')
  238. expect(controller.store.getSnapshot().view?.title).toBe('mine')
  239. })
  240. it('closes without touching the list', async () => {
  241. const { controller } = harness()
  242. await controller.load()
  243. await controller.view('standard')
  244. controller.closeView()
  245. expect(controller.store.getSnapshot().view).toBeNull()
  246. expect(controller.store.getSnapshot().rows).toHaveLength(2)
  247. })
  248. it('puts a read refusal on the page rather than opening empty', async () => {
  249. const { controller } = harness({ failRead: 'no peeking' })
  250. await controller.load()
  251. await controller.view('standard')
  252. expect(controller.store.getSnapshot().view).toBeNull()
  253. expect(controller.store.getSnapshot().error).toBe('no peeking')
  254. })
  255. })
  256. describe('the copy dialog', () => {
  257. it('opens over the source with its display name in the title', async () => {
  258. const { controller } = harness()
  259. await controller.load()
  260. controller.beginCopy('standard')
  261. expect(copyOf(controller)).toMatchObject({
  262. from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
  263. })
  264. })
  265. it('falls back to the source id when it published no name', async () => {
  266. const { controller } = harness()
  267. await controller.load()
  268. controller.beginCopy('mine')
  269. expect(copyOf(controller).fromTitle).toBe('mine')
  270. })
  271. it('cancel discards whatever was typed', async () => {
  272. const { controller } = harness()
  273. await controller.load()
  274. controller.beginCopy('standard')
  275. controller.setCopyId('half-typed')
  276. controller.cancelCopy()
  277. expect(controller.store.getSnapshot().copy).toBeNull()
  278. })
  279. it('ignores field edits and submits with no dialog open', async () => {
  280. const { controller, calls } = harness()
  281. await controller.load()
  282. controller.setCopyId('typed-into-nothing')
  283. controller.setCopyName('nameless')
  284. await controller.confirmCopy()
  285. expect(controller.store.getSnapshot().copy).toBeNull()
  286. expect(calls.some(call => call.method === 'copy')).toBe(false)
  287. })
  288. it('typing clears the previous failure', async () => {
  289. const { controller } = harness({ failCopy: 'disk full' })
  290. await controller.load()
  291. controller.beginCopy('standard')
  292. controller.setCopyId('my-copy')
  293. await controller.confirmCopy()
  294. expect(copyOf(controller).error).toBe('disk full')
  295. controller.setCopyName('renamed')
  296. expect(copyOf(controller).error).toBeNull()
  297. })
  298. })
  299. describe('the copy blocker', () => {
  300. const rows: PresetRow[] = [
  301. { id: 'standard', trust: 'system', isDefault: true },
  302. { id: 'mine', trust: 'user', isDefault: false },
  303. ]
  304. const draft = (id: string): CopyDraft =>
  305. ({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
  306. it('requires an id, a containable shape, and a free name', () => {
  307. expect(draftBlocker(draft(''), rows)).toBe('idRequired')
  308. expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
  309. expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
  310. expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
  311. expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
  312. })
  313. })
  314. describe('submitting a copy', () => {
  315. it('copies, re-reads the roster, announces the change, and opens the files', async () => {
  316. const { controller, calls, rosterChanges } = harness()
  317. await controller.load()
  318. controller.beginCopy('standard')
  319. controller.setCopyId('my-copy')
  320. controller.setCopyName('我的模式')
  321. await controller.confirmCopy()
  322. const state = controller.store.getSnapshot()
  323. expect(state.copy).toBeNull()
  324. expect(state.rows.map(row => row.id)).toContain('my-copy')
  325. expect(rosterChanges()).toBe(1)
  326. expect(calls.find(call => call.method === 'copy')?.payload)
  327. .toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' })
  328. // A preset is its files from here on, so landing in them completes the
  329. // copy rather than following it.
  330. expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload)
  331. .toEqual({ agentPreset: 'my-copy' })
  332. })
  333. it('omits an empty name so the copy falls back to its id', async () => {
  334. const { controller, calls } = harness()
  335. await controller.load()
  336. controller.beginCopy('standard')
  337. controller.setCopyId('my-copy')
  338. controller.setCopyName(' ')
  339. await controller.confirmCopy()
  340. expect(calls.find(call => call.method === 'copy')?.payload)
  341. .toEqual({ from: 'standard', id: 'my-copy' })
  342. })
  343. it('reveals the new directory as text where the host has no desktop', async () => {
  344. const { controller } = harness({ hasDocument: false })
  345. await controller.load()
  346. controller.beginCopy('standard')
  347. controller.setCopyId('my-copy')
  348. await controller.confirmCopy()
  349. expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
  350. })
  351. it('keeps the dialog open with the refusal on it', async () => {
  352. const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
  353. await controller.load()
  354. controller.beginCopy('standard')
  355. controller.setCopyId('my-copy')
  356. await controller.confirmCopy()
  357. expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
  358. expect(rosterChanges()).toBe(0)
  359. })
  360. it('refuses to submit while blocked or already saving', async () => {
  361. const { controller, calls } = harness()
  362. await controller.load()
  363. controller.beginCopy('standard')
  364. controller.setCopyId('mine')
  365. await controller.confirmCopy()
  366. expect(calls.some(call => call.method === 'copy')).toBe(false)
  367. })
  368. })
  369. describe('the location action', () => {
  370. it('opens the directory and leaves the page alone on a desktop host', async () => {
  371. const { controller, calls } = harness()
  372. await controller.load()
  373. await controller.openLocation('mine')
  374. expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload).toEqual({ agentPreset: 'mine' })
  375. expect(controller.store.getSnapshot().revealedPaths).toEqual({})
  376. })
  377. it('reveals the path on the row where the host has none', async () => {
  378. const { controller } = harness({ hasDocument: false })
  379. await controller.load()
  380. await controller.openLocation('mine')
  381. expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
  382. })
  383. it('drops a revealed path once its preset leaves the roster', async () => {
  384. const { controller, presets } = harness({ hasDocument: false })
  385. await controller.load()
  386. await controller.openLocation('mine')
  387. presets.delete('mine')
  388. await controller.load()
  389. expect(controller.store.getSnapshot().revealedPaths).toEqual({})
  390. })
  391. it('surfaces a refusal as the page error', async () => {
  392. const { controller } = harness({ failOpen: 'not yours' })
  393. await controller.load()
  394. await controller.openLocation('mine')
  395. expect(controller.store.getSnapshot().error).toBe('not yours')
  396. })
  397. })
  398. describe('deleting', () => {
  399. it('asks first, then deletes, re-reads, and announces the change', async () => {
  400. const { controller, rosterChanges } = harness()
  401. await controller.load()
  402. controller.confirmDelete('mine')
  403. expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
  404. await controller.remove()
  405. const state = controller.store.getSnapshot()
  406. expect(state.pendingDelete).toBeNull()
  407. expect(state.rows.map(row => row.id)).not.toContain('mine')
  408. expect(rosterChanges()).toBe(1)
  409. })
  410. it('dismisses the confirmation without deleting', async () => {
  411. const { controller, calls } = harness()
  412. await controller.load()
  413. controller.confirmDelete('mine')
  414. controller.confirmDelete(null)
  415. await controller.remove()
  416. expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
  417. expect(calls.some(call => call.method === 'deletePreset')).toBe(false)
  418. })
  419. it('ignores a second confirmation while one delete is in flight', async () => {
  420. let release = (): void => {}
  421. const gate = new Promise<void>((resolve) => { release = resolve })
  422. const { controller, calls } = harness({ holdRemove: gate })
  423. await controller.load()
  424. controller.confirmDelete('mine')
  425. const removal = controller.remove()
  426. controller.confirmDelete('standard')
  427. await controller.remove()
  428. release()
  429. await removal
  430. expect(calls.filter(call => call.method === 'deletePreset')).toHaveLength(1)
  431. })
  432. it('surfaces a refusal and clears the confirmation', async () => {
  433. const { controller } = harness({ failRemove: 'shipped preset' })
  434. await controller.load()
  435. controller.confirmDelete('mine')
  436. await controller.remove()
  437. const state = controller.store.getSnapshot()
  438. expect(state.error).toBe('shipped preset')
  439. expect(state.pendingDelete).toBeNull()
  440. expect(state.deleting).toBe(false)
  441. })
  442. })
  443. describe('a controller with no roster listener', () => {
  444. it('completes a delete without anyone to notify', async () => {
  445. // The rosterChanged callback is optional wiring, not a requirement: a
  446. // page composed without sibling surfaces still deletes cleanly.
  447. const presets = seed()
  448. const defaultId = { id: 'standard' }
  449. const alone = new AgentPresetSectionController(
  450. fakeCtx(presets, defaultId))
  451. await alone.load()
  452. alone.confirmDelete('mine')
  453. await alone.remove()
  454. expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
  455. })
  456. })
  457. describe('the default preset', () => {
  458. it('syncs the Host value that wins before the confirming read', async () => {
  459. const { controller, defaultId } = harness()
  460. const synced: string[] = []
  461. const sync = (id: string): Promise<undefined> => {
  462. synced.push(id)
  463. return Promise.resolve(undefined)
  464. }
  465. await controller.load()
  466. const makingDefault = controller.makeDefault('mine', sync)
  467. defaultId.id = 'standard'
  468. await makingDefault
  469. expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
  470. expect(synced).toEqual(['standard'])
  471. const missingDefault = controller.makeDefault('mine', sync)
  472. defaultId.id = 'missing'
  473. await missingDefault
  474. expect(synced).toEqual(['standard'])
  475. })
  476. it('surfaces a settings refusal as the page error', async () => {
  477. const { controller } = harness({ failSettings: 'read-only settings' })
  478. await controller.load()
  479. await controller.makeDefault('mine')
  480. expect(controller.store.getSnapshot().error).toContain('read-only settings')
  481. })
  482. it('keeps a composition sync failure on the page', async () => {
  483. const { controller } = harness()
  484. await controller.load()
  485. await controller.makeDefault(
  486. 'mine',
  487. () => Promise.resolve('blank session rejected the preset'),
  488. )
  489. expect(controller.store.getSnapshot()).toMatchObject({
  490. error: 'blank session rejected the preset', policySaving: false,
  491. })
  492. })
  493. it('restores the policy lock after a thrown default write', async () => {
  494. const { controller } = harness({ throwSettings: 'settings transport unavailable' })
  495. await controller.load()
  496. await controller.makeDefault('mine')
  497. expect(controller.store.getSnapshot()).toMatchObject({
  498. error: 'settings transport unavailable', policySaving: false,
  499. })
  500. })
  501. })
  502. describe('the new-session picker preference', () => {
  503. it('ignores policy writes until the section is ready', async () => {
  504. const { controller, calls } = harness({ showPicker: true })
  505. await controller.setPickerVisible(false)
  506. expect(calls.some(call => call.method === 'settings.update')).toBe(false)
  507. })
  508. it('uses Standard while disabled and restores the saved default when re-enabled', async () => {
  509. const { controller, calls, defaultId } = harness({
  510. showPicker: true, failList: 'connection moved', failListAt: 2,
  511. })
  512. const synced: string[] = []
  513. const sync = (id: string): Promise<undefined> => {
  514. synced.push(id)
  515. return Promise.resolve(undefined)
  516. }
  517. defaultId.id = 'mine'
  518. await controller.load()
  519. expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
  520. await controller.setPickerVisible(false, sync)
  521. expect(calls.filter(call => call.method === 'settings.update')[0]?.payload)
  522. .toEqual({ ns: 'agent-presets', patch: { modeSelectionEnabled: false } })
  523. expect(controller.store.getSnapshot()).toMatchObject({
  524. showPicker: false, policySaving: false,
  525. })
  526. expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
  527. await controller.setPickerVisible(true, sync)
  528. expect(calls.filter(call => call.method === 'settings.update')[1]?.payload)
  529. .toEqual({ ns: 'agent-presets', patch: { modeSelectionEnabled: true } })
  530. expect(controller.store.getSnapshot()).toMatchObject({
  531. showPicker: true, policySaving: false,
  532. })
  533. expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
  534. expect(synced).toEqual(['standard', 'mine'])
  535. })
  536. it('reloads Host truth and reports a refused visibility write', async () => {
  537. const { controller } = harness({ showPicker: true, failSettings: 'read-only settings' })
  538. await controller.load()
  539. await controller.setPickerVisible(false)
  540. expect(controller.store.getSnapshot()).toMatchObject({
  541. showPicker: true, error: 'read-only settings', policySaving: false,
  542. })
  543. })
  544. it('reloads Host truth when another policy value wins the write', async () => {
  545. const { controller } = harness({ showPicker: true, ignorePickerWrite: true })
  546. await controller.load()
  547. await controller.setPickerVisible(false)
  548. expect(controller.store.getSnapshot()).toMatchObject({ showPicker: true, policySaving: false })
  549. })
  550. it('reloads a roster that cannot mark an effective default', async () => {
  551. const { controller, defaultId } = harness({ showPicker: false })
  552. defaultId.id = 'missing'
  553. await controller.load()
  554. await controller.setPickerVisible(true)
  555. expect(controller.store.getSnapshot()).toMatchObject({ showPicker: true, policySaving: false })
  556. expect(controller.store.getSnapshot().rows.every(row => !row.isDefault)).toBe(true)
  557. })
  558. it('keeps a blank-task sync failure on the page', async () => {
  559. const { controller } = harness({ showPicker: true })
  560. await controller.load()
  561. await controller.setPickerVisible(
  562. false,
  563. () => Promise.resolve('blank session rejected the policy'),
  564. )
  565. expect(controller.store.getSnapshot()).toMatchObject({
  566. error: 'blank session rejected the policy', policySaving: false,
  567. })
  568. })
  569. it('reloads Host truth after a thrown visibility write', async () => {
  570. const { controller } = harness({ showPicker: true, throwSettings: new Error('connection lost') })
  571. await controller.load()
  572. await controller.setPickerVisible(false)
  573. expect(controller.store.getSnapshot()).toMatchObject({
  574. showPicker: true, error: 'connection lost', policySaving: false,
  575. })
  576. })
  577. it('ignores default writes while mode selection is disabled', async () => {
  578. const { controller, calls } = harness({ showPicker: false })
  579. await controller.load()
  580. await controller.makeDefault('mine')
  581. expect(calls.some(call => call.method === 'settings.update')).toBe(false)
  582. })
  583. })