manager-store.client.spec.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756
  1. /**
  2. * The manager store: what it reads, how actions cross the wire, which
  3. * outcomes become toasts, and how the install run folds its output.
  4. */
  5. import { describe, expect, it, vi } from 'vitest'
  6. import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
  7. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  8. import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
  9. import type { ConfigLedger } from '../src/client/config-ledger.ts'
  10. import { packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts'
  11. const ROW_ENTRY = 'include:sidebar' as PluginEntryId
  12. const BUNDLE: BundleInfo = {
  13. name: 'dsh-better-sidebar',
  14. version: '0.16.0',
  15. description: 'A sidebar.',
  16. enabled: false,
  17. installed: true,
  18. optional: false,
  19. removable: true,
  20. rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }],
  21. overrides: [],
  22. }
  23. const PLUGINS: PluginInfo[] = [
  24. { entryId: ROW_ENTRY, moduleName: 'dsh-better-sidebar', enabled: true, fiberPhase: 'active', patchId: 'sidebar' },
  25. { entryId: 'include:core' as PluginEntryId, moduleName: '@deepseek-ai/dsh-base', enabled: true, fiberPhase: 'active', readOnlyReason: 'management-required' },
  26. ]
  27. /** What the check answers for a registry name. */
  28. const INSPECTED = { status: 'accepted' as const, kind: 'registry' as const, name: 'dsh-better-sidebar', version: '1.0.0', bundle: true }
  29. const APPLIED: ChangeResult = { changed: true, application: 'applied', stage: 'enable', target: 'dsh-better-sidebar' }
  30. /** A change the Host could not apply, with the refusal it names. */
  31. function failed(error?: ManagementError, packageResult?: ChangeResult['packageResult']): ChangeResult {
  32. return {
  33. changed: false, application: 'failed', stage: 'enable', target: 'dsh-better-sidebar',
  34. ...error === undefined ? {} : { error }, ...packageResult === undefined ? {} : { packageResult },
  35. }
  36. }
  37. function ok<T>(value: T) {
  38. return { ok: true as const, value }
  39. }
  40. function refused(code: string, message: string, details: object = {}) {
  41. // The double's code map is keyed by literal codes; a spec-chosen string stands in.
  42. return { ok: false as const, error: new RemoteError(code as never, message, details as never) }
  43. }
  44. function deferred<T>() {
  45. let resolve!: (value: T) => void
  46. const promise = new Promise<T>((res) => { resolve = res })
  47. return { promise, resolve }
  48. }
  49. /** A configuration ledger with nothing registered, as the page binds it beside the store. */
  50. const NO_CONFIG: HostObservable<ConfigLedger> = {
  51. getSnapshot: () => ({ items: [], bundles: new Set(), rows: new Set() }),
  52. subscribe: () => () => {},
  53. }
  54. function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
  55. const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) }
  56. const plugins = {
  57. listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
  58. listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
  59. inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
  60. installBundle: vi.fn(() => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))),
  61. cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))),
  62. removeBundle: vi.fn(() => Promise.resolve(ok(APPLIED))),
  63. setBundleEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
  64. setPluginEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
  65. ...overrides,
  66. }
  67. const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never
  68. const controller = new PluginManagerController(ctx)
  69. const face = controller.inject(NO_CONFIG)
  70. const state = () => controller.getSnapshot()
  71. /** The request id of the run the dialog just handed to the Host. */
  72. const started = async (): Promise<PluginInstallRequestId> => {
  73. await vi.waitFor(() => { expect(state().install.phase).toBe('starting') })
  74. return state().install.requestId as PluginInstallRequestId
  75. }
  76. return { plugins, inventory, controller, face, state, started }
  77. }
  78. describe('packageView', () => {
  79. it('joins a bundle with the entries its rows run as', () => {
  80. expect(packageView(BUNDLE, PLUGINS)).toEqual({
  81. name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.',
  82. installed: true, optional: false, enabled: false,
  83. rows: [
  84. { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' },
  85. { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null },
  86. ],
  87. })
  88. // A row the inventory no longer lists, a protected row, and a bundle the Host cannot read.
  89. const protectedBundle: BundleInfo = {
  90. name: '@deepseek-ai/dsh-base', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required',
  91. error: { code: 'operation-error', diagnostic: 'broken' },
  92. rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }],
  93. overrides: [],
  94. }
  95. expect(packageView(protectedBundle, PLUGINS)).toEqual({
  96. name: '@deepseek-ai/dsh-base', installed: false, optional: false, enabled: true, readOnlyReason: 'management-required',
  97. error: { code: 'operation-error', diagnostic: 'broken' },
  98. rows: [
  99. { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' },
  100. { rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null },
  101. ],
  102. })
  103. })
  104. })
  105. describe('sortPackages', () => {
  106. it('orders packages by the short name a person reads, not by the Host order or enablement', async () => {
  107. const plain = { enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [] }
  108. const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' }
  109. const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', enabled: false }
  110. const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS))
  111. expect(sortPackages(views).map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta'])
  112. // The store lists what it read in that order, whatever the Host's order.
  113. const { state, controller } = bench({ listBundles: vi.fn(() => Promise.resolve(ok([zeta, BUNDLE, alpha]))) })
  114. await controller.load()
  115. expect(state().packages.map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta'])
  116. })
  117. })
  118. describe('PluginManagerController', () => {
  119. it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => {
  120. const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
  121. const { plugins, inventory, face, state, controller } = bench({
  122. listBundles: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])),
  123. })
  124. expect(state().status).toBe('idle')
  125. face.ensure()
  126. face.ensure()
  127. await Promise.resolve()
  128. expect(state().status).toBe('loading')
  129. const mid = controller.load()
  130. gate.resolve(ok([BUNDLE]))
  131. await mid
  132. expect(state().status).toBe('ready')
  133. expect(state().packages).toEqual([packageView(BUNDLE, PLUGINS)])
  134. expect(inventory.list).toHaveBeenCalledTimes(2)
  135. expect(plugins.listBundles).toHaveBeenCalledTimes(2)
  136. expect(plugins.listPlugins).toHaveBeenCalledTimes(2)
  137. face.ensure()
  138. expect(plugins.listBundles).toHaveBeenCalledTimes(2)
  139. })
  140. it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => {
  141. const { inventory, plugins, face, state, controller } = bench()
  142. await controller.load()
  143. expect(state().packages).toHaveLength(1)
  144. inventory.list.mockResolvedValueOnce(ok({ entries: [] }))
  145. await controller.load()
  146. expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
  147. inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
  148. await controller.load()
  149. expect(state().status).toBe('error')
  150. await controller.load()
  151. expect(state().status).toBe('ready')
  152. plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
  153. await controller.load()
  154. expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] })
  155. plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
  156. await controller.load()
  157. expect(state().status).toBe('error')
  158. face.refresh()
  159. await vi.waitFor(() => { expect(state().status).toBe('ready') })
  160. })
  161. it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => {
  162. const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  163. const { plugins, face, state, controller } = bench({
  164. setBundleEnabled: vi.fn()
  165. .mockReturnValueOnce(gate.promise)
  166. .mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' }))
  167. .mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })),
  168. })
  169. await controller.load()
  170. face.setEnabled(BUNDLE.name, true)
  171. face.setEnabled(BUNDLE.name, true)
  172. await Promise.resolve()
  173. expect(state().busy).toEqual([BUNDLE.name])
  174. expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(BUNDLE.name, true)
  175. gate.resolve(ok(APPLIED))
  176. await vi.waitFor(() => { expect(state().busy).toEqual([]) })
  177. expect(state().notice).toBeNull()
  178. expect(plugins.listBundles).toHaveBeenCalledTimes(2)
  179. face.setEnabled(BUNDLE.name, false)
  180. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 }) })
  181. face.setEnabled(BUNDLE.name, false)
  182. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'overridden', packageName: BUNDLE.name, seq: 2 }) })
  183. })
  184. it('turns a change the Host could not apply, or a refused answer, into a notice carrying its code and words', async () => {
  185. const { face, state, controller } = bench({
  186. setBundleEnabled: vi.fn()
  187. .mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' })))
  188. .mockResolvedValueOnce(refused('gateway/internal', 'offline'))
  189. .mockRejectedValueOnce(new Error('transport down'))
  190. .mockRejectedValueOnce('odd')
  191. .mockResolvedValueOnce(ok(failed({ code: 'bundle-in-use' })))
  192. .mockResolvedValueOnce(ok(failed()))
  193. .mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' })),
  194. })
  195. await controller.load()
  196. face.setEnabled(BUNDLE.name, true)
  197. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: BUNDLE.name, seq: 1 }) })
  198. face.setEnabled(BUNDLE.name, false)
  199. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'disable', reason: 'offline', packageName: BUNDLE.name, seq: 2 }) })
  200. face.setEnabled(BUNDLE.name, true)
  201. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'transport down', packageName: BUNDLE.name, seq: 3 }) })
  202. face.setEnabled(BUNDLE.name, true)
  203. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'odd', packageName: BUNDLE.name, seq: 4 }) })
  204. // A refusal keeps its code with no words of its own; a failure without a code has neither.
  205. face.setEnabled(BUNDLE.name, true)
  206. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'bundle-in-use', reason: '', packageName: BUNDLE.name, seq: 5 }) })
  207. face.setEnabled(BUNDLE.name, true)
  208. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: '', packageName: BUNDLE.name, seq: 6 }) })
  209. // A change the Host stopped is said in passing.
  210. face.setEnabled(BUNDLE.name, true)
  211. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 7 }) })
  212. face.dismissNotice()
  213. expect(state().notice).toBeNull()
  214. })
  215. it('always asks before uninstalling, and cancelling runs nothing', async () => {
  216. const { plugins, face, state, controller } = bench()
  217. await controller.load()
  218. face.uninstall(BUNDLE.name)
  219. expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name })
  220. face.cancelConfirm()
  221. expect(state().confirm).toBeNull()
  222. face.confirm()
  223. expect(plugins.removeBundle).not.toHaveBeenCalled()
  224. face.uninstall(BUNDLE.name)
  225. face.confirm()
  226. expect(state().confirm).toBeNull()
  227. await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) })
  228. await vi.waitFor(() => { expect(state().busy).toEqual([]) })
  229. // A refused removal names the action it was.
  230. plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never)
  231. face.uninstall(BUNDLE.name)
  232. face.confirm()
  233. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'uninstall', code: 'not-removable', reason: '', packageName: BUNDLE.name, seq: 1 }) })
  234. })
  235. it('switches rows under their own busy keys and reports what the Host said', async () => {
  236. const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  237. const { plugins, face, state, controller } = bench({
  238. setPluginEnabled: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValueOnce(ok(failed({ code: 'unaddressable' }))),
  239. })
  240. await controller.load()
  241. face.setRowEnabled(ROW_ENTRY, false)
  242. face.setRowEnabled(ROW_ENTRY, false)
  243. await Promise.resolve()
  244. expect(state().busy).toEqual([rowKey(ROW_ENTRY)])
  245. expect(plugins.setPluginEnabled).toHaveBeenCalledExactlyOnceWith(ROW_ENTRY, false)
  246. gate.resolve(ok(APPLIED))
  247. await vi.waitFor(() => { expect(state().busy).toEqual([]) })
  248. face.setRowEnabled(ROW_ENTRY, true)
  249. await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'rowEnable', code: 'unaddressable', reason: '', packageName: ROW_ENTRY, seq: 1 }) })
  250. })
  251. it('checks the spec, hands the run to the Host, and folds the chunks that carry its request id', async () => {
  252. const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  253. const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
  254. await controller.load()
  255. face.runInstall()
  256. expect(plugins.inspect).not.toHaveBeenCalled()
  257. face.openInstall()
  258. expect(state().install).toMatchObject({ open: true, spec: '', phase: 'idle', inputError: null, subject: null })
  259. face.editInstallSpec(' dsh-new ')
  260. face.runInstall()
  261. face.runInstall()
  262. expect(state().install.phase).toBe('checking')
  263. // Neither typing nor a second run reaches the Host while it checks.
  264. face.editInstallSpec('other')
  265. expect(state().install.spec).toBe(' dsh-new ')
  266. expect(plugins.inspect).toHaveBeenCalledTimes(1)
  267. expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', expect.any(AbortSignal))
  268. const requestId = await started()
  269. expect(state().install.subject).toEqual({ spec: 'dsh-new', ...INSPECTED })
  270. expect(plugins.installBundle).toHaveBeenCalledTimes(1)
  271. expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', { enabled: false, requestId })
  272. // The Host's acknowledgement makes the run stoppable; a chunk of another request is not this run's.
  273. controller.installProgress({ requestId, phase: 'installing' })
  274. expect(state().install.phase).toBe('running')
  275. controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'j1', argv: [], cwd: '/p', stream: 'stdout', text: 'x' })
  276. expect(state().install.runs).toEqual([])
  277. const argv = ['pnpm', 'add', 'dsh-new']
  278. controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: 'Progress\n' })
  279. // A second run of the same install is its own terminal; a later chunk lands on the run it names.
  280. controller.appendLog({ requestId, jobId: 'j2', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', stream: 'stdout', text: '- lib\n', exitCode: 0 })
  281. controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stderr', text: 'Done\n' })
  282. expect(state().install.runs).toEqual([
  283. { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n' },
  284. { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
  285. ])
  286. face.toggleInstallDetails()
  287. expect(state().install.detailsOpen).toBe(true)
  288. gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))
  289. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  290. expect(state().install).toMatchObject({ installed: 'dsh-new', restartRequired: false, detailsOpen: true })
  291. // The finished install settled its run; a trailing last chunk still lands
  292. // on it, while a chunk for a run the dialog never saw is dropped.
  293. controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: '', exitCode: 0 })
  294. controller.appendLog({ requestId, jobId: 'j3', argv, cwd: '/p', stream: 'stdout', text: 'stray' })
  295. expect(state().install.runs).toEqual([
  296. { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n', exitCode: 0 },
  297. { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
  298. ])
  299. await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) })
  300. // Cancelling from the finished screen does nothing, nor does the Host's late progress; a new spec after it starts over.
  301. face.cancelInstall()
  302. controller.installProgress({ requestId, phase: 'applying' })
  303. expect(state().install.phase).toBe('done')
  304. face.editInstallSpec('another')
  305. expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', runs: [], installed: null, subject: null })
  306. face.closeInstall()
  307. expect(state().install.open).toBe(false)
  308. })
  309. it('refuses a spec the list already shows without asking the Host, and words what the Host refused', async () => {
  310. const { plugins, face, state, controller } = bench({
  311. inspect: vi.fn()
  312. .mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-found', reason: 'E404' }))
  313. .mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }))
  314. .mockResolvedValueOnce(refused('gateway/internal', 'offline')),
  315. })
  316. await controller.load()
  317. face.openInstall()
  318. face.editInstallSpec(BUNDLE.name)
  319. face.runInstall()
  320. expect(plugins.inspect).not.toHaveBeenCalled()
  321. expect(state().install).toMatchObject({ phase: 'idle', inputError: { problem: 'already-installed', reason: BUNDLE.name } })
  322. // Typing clears the refusal.
  323. face.editInstallSpec('nope')
  324. expect(state().install.inputError).toBeNull()
  325. face.runInstall()
  326. await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-found', reason: 'E404' }) })
  327. expect(state().install.phase).toBe('idle')
  328. expect(plugins.installBundle).not.toHaveBeenCalled()
  329. face.editInstallSpec('plain')
  330. face.runInstall()
  331. await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }) })
  332. // A refused answer, rather than a refused spec, reads as unknown with the transport's words.
  333. face.editInstallSpec('x')
  334. face.runInstall()
  335. await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'offline' }) })
  336. })
  337. it('leaves the check or the failed screen for the spec at once', async () => {
  338. const inspectGate = deferred<ReturnType<typeof ok<typeof INSPECTED>>>()
  339. const { plugins, face, state, controller } = bench({
  340. inspect: vi.fn().mockReturnValueOnce(inspectGate.promise).mockResolvedValue(ok(INSPECTED)),
  341. installBundle: vi.fn().mockResolvedValue(ok(failed({ code: 'operation-error', diagnostic: 'ERR' }, { exitCode: 1, output: 'ERR', truncated: false, logPath: '/l', kind: 'network' }))),
  342. })
  343. await controller.load()
  344. face.openInstall()
  345. face.editInstallSpec('dsh-x')
  346. face.runInstall()
  347. const checkSignal = (plugins.inspect.mock.calls[0] as unknown[])[1] as AbortSignal
  348. face.cancelInstall()
  349. expect(checkSignal.aborted).toBe(true)
  350. expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', inputError: null })
  351. // The settlement of the dropped check changes nothing.
  352. inspectGate.resolve(ok(INSPECTED))
  353. await Promise.resolve()
  354. await Promise.resolve()
  355. expect(state().install.phase).toBe('idle')
  356. expect(plugins.installBundle).not.toHaveBeenCalled()
  357. // Closing during a check drops it too.
  358. face.runInstall()
  359. face.closeInstall()
  360. expect(state().install.open).toBe(false)
  361. expect((plugins.inspect.mock.calls[1] as unknown[])[1]).toMatchObject({ aborted: true })
  362. // From the failed screen the same control goes back to the spec.
  363. face.openInstall()
  364. face.editInstallSpec('dsh-x')
  365. face.runInstall()
  366. await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
  367. expect(state().install.failure).toEqual({ reason: 'ERR', code: 'operation-error', kind: 'network' })
  368. face.cancelInstall()
  369. expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', failure: null, subject: null })
  370. })
  371. it('asks the Host to stop a run, keeps the spec once it confirms, and forgets the stopped run', async () => {
  372. const first = deferred<ReturnType<typeof ok<ChangeResult>>>()
  373. const second = deferred<ReturnType<typeof ok<ChangeResult>>>()
  374. const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
  375. const { plugins, face, state, controller, started } = bench({
  376. installBundle: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
  377. cancelInstall: vi.fn().mockReturnValueOnce(cancellation.promise),
  378. })
  379. face.openInstall()
  380. face.editInstallSpec('slow')
  381. face.runInstall()
  382. const requestId = await started()
  383. // Before the Host acknowledges the run there is nothing to stop, and the dialog cannot close.
  384. face.cancelInstall()
  385. face.closeInstall()
  386. expect(plugins.cancelInstall).not.toHaveBeenCalled()
  387. expect(state().install).toMatchObject({ open: true, phase: 'starting' })
  388. controller.installProgress({ requestId, phase: 'installing' })
  389. face.cancelInstall()
  390. face.cancelInstall()
  391. face.closeInstall()
  392. face.openInstall()
  393. expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
  394. expect(state().install).toMatchObject({ phase: 'cancelling', open: true, spec: 'slow' })
  395. // A queued start cannot undo the request to stop; the Host's own cancelling phase is the same.
  396. controller.installProgress({ requestId, phase: 'installing' })
  397. controller.installProgress({ requestId, phase: 'cancelling' })
  398. expect(state().install.phase).toBe('cancelling')
  399. cancellation.resolve(ok({ status: 'cancelled' }))
  400. await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
  401. // The spec is offered again, the run is forgotten, and a toast says the Host stopped it.
  402. expect(state().install).toMatchObject({ open: true, spec: 'slow', subject: null, runs: [] })
  403. expect(state().install.requestId).toBeUndefined()
  404. expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
  405. face.runInstall()
  406. const nextId = await started()
  407. expect(nextId).not.toBe(requestId)
  408. // The stopped run's answer, progress, and chunks belong to a request the dialog no longer has.
  409. first.resolve(ok({ ...failed(), application: 'cancelled' }))
  410. await Promise.resolve()
  411. await Promise.resolve()
  412. expect(state().install).toMatchObject({ requestId: nextId, phase: 'starting' })
  413. controller.installProgress({ requestId, phase: 'applying' })
  414. controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', stream: 'stdout', text: 'late' })
  415. expect(state().install).toMatchObject({ phase: 'starting', runs: [] })
  416. second.resolve(ok({ ...APPLIED, application: 'restart-required', bundle: 'slow' }))
  417. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  418. expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true })
  419. })
  420. it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => {
  421. const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
  422. const { plugins, face, state, controller, started } = bench({
  423. installBundle: vi.fn().mockReturnValue(pending.promise),
  424. cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })),
  425. })
  426. face.openInstall()
  427. face.editInstallSpec('slow')
  428. face.runInstall()
  429. const requestId = await started()
  430. controller.installProgress({ requestId, phase: 'installing' })
  431. face.cancelInstall()
  432. await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'running') })
  433. expect(plugins.cancelInstall).toHaveBeenCalledOnce()
  434. // A stop the Host did not confirm says so over the running screen, with the transport's words when it has them.
  435. expect(state().install.failure).toEqual(
  436. status === 'too-late' ? null : { reason: status === 'offline' ? 'offline' : '', cancelUnconfirmed: true },
  437. )
  438. // The Host's own word that it stopped the run still ends it.
  439. pending.resolve(ok({ ...failed(), application: 'cancelled' }))
  440. await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
  441. expect(state().install).toMatchObject({ open: true, spec: 'slow', failure: null })
  442. expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
  443. })
  444. it.each(['cancelled', 'too-late'] as const)('closes the dialog once the Host confirms the stop its close control asked for, and stays on %s', async (status) => {
  445. const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
  446. const { plugins, face, state, controller, started } = bench({
  447. installBundle: vi.fn().mockReturnValue(pending.promise),
  448. cancelInstall: vi.fn().mockResolvedValue(ok({ status })),
  449. })
  450. // Before a run exists there is nothing to stop, and the dialog stays as it is.
  451. face.openInstall()
  452. face.cancelInstallAndClose()
  453. expect(state().install).toMatchObject({ open: true, phase: 'idle' })
  454. face.editInstallSpec('slow')
  455. face.runInstall()
  456. const requestId = await started()
  457. controller.installProgress({ requestId, phase: 'installing' })
  458. face.cancelInstallAndClose()
  459. expect(state().install.phase).toBe('cancelling')
  460. if (status === 'cancelled') {
  461. await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'idle', spec: '' }) })
  462. expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
  463. } else {
  464. await vi.waitFor(() => { expect(state().install.phase).toBe('applying') })
  465. expect(state().install.open).toBe(true)
  466. }
  467. expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
  468. pending.resolve(ok({ ...failed(), application: 'cancelled' }))
  469. })
  470. it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
  471. const answer = deferred<ReturnType<typeof ok<ChangeResult>>>()
  472. const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
  473. const { face, state, controller, started } = bench({
  474. installBundle: vi.fn().mockReturnValue(answer.promise),
  475. cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
  476. })
  477. await controller.load()
  478. face.openInstall()
  479. face.editInstallSpec('slow')
  480. face.runInstall()
  481. controller.installProgress({ requestId: await started(), phase: 'installing' })
  482. face.cancelInstall()
  483. expect(state().install.phase).toBe('cancelling')
  484. if (dispose) controller.dispose()
  485. answer.resolve(ok({ ...APPLIED, bundle: 'slow' }))
  486. if (!dispose) await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  487. cancellation.resolve(ok({ status: 'cancelled' }))
  488. await Promise.resolve()
  489. await Promise.resolve()
  490. expect(state().install.phase).toBe(dispose ? 'cancelling' : 'done')
  491. expect(state().notice).toBeNull()
  492. })
  493. it('enables what a finished install added from its screen, closes, and marks it in the list', async () => {
  494. const { plugins, face, state, controller } = bench({
  495. installBundle: vi.fn()
  496. .mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' }))
  497. .mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' }))
  498. .mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })),
  499. setBundleEnabled: vi.fn()
  500. .mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' }))
  501. .mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' }))),
  502. })
  503. await controller.load()
  504. face.enableInstalled()
  505. expect(plugins.setBundleEnabled).not.toHaveBeenCalled()
  506. face.openInstall()
  507. face.editInstallSpec('dsh-a')
  508. face.runInstall()
  509. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  510. face.enableInstalled()
  511. face.enableInstalled()
  512. expect(state().install.enabling).toBe(true)
  513. await vi.waitFor(() => { expect(state().install.open).toBe(false) })
  514. expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith('dsh-a', true)
  515. // A restart it waits for is said in passing; the list marks it.
  516. expect(state().notice).toEqual({ kind: 'restart', packageName: 'dsh-a', seq: 1 })
  517. expect(state().highlight).toBe('dsh-a')
  518. face.clearHighlight()
  519. face.clearHighlight()
  520. expect(state().highlight).toBeNull()
  521. // A refusal toasts it and still closes.
  522. face.openInstall()
  523. face.editInstallSpec('dsh-a')
  524. face.runInstall()
  525. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  526. face.enableInstalled()
  527. await vi.waitFor(() => { expect(state().install.open).toBe(false) })
  528. expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2)
  529. expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: 'dsh-a', seq: 2 })
  530. expect(state().highlight).toBe('dsh-a')
  531. // An install that named no bundle has nothing to enable or mark: the screen just closes.
  532. face.openInstall()
  533. face.editInstallSpec('lib')
  534. face.runInstall()
  535. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  536. expect(state().install.installed).toBeNull()
  537. face.enableInstalled()
  538. await vi.waitFor(() => { expect(state().install.open).toBe(false) })
  539. expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2)
  540. expect(state().highlight).toBeNull()
  541. })
  542. it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => {
  543. const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
  544. const { face, state, controller, plugins, started } = bench({
  545. installBundle: vi.fn(() => {
  546. const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
  547. gates.push(gate)
  548. return gate.promise
  549. }),
  550. })
  551. await controller.load()
  552. face.openInstall()
  553. face.editInstallSpec('x')
  554. // Before the failed screen offers anything, the action does nothing.
  555. face.approveBuildsAndRetry()
  556. face.runInstall()
  557. const first = await started()
  558. gates[0]!.resolve(ok({
  559. ...failed({ code: 'operation-error', diagnostic: 'ERR_PNPM_IGNORED_BUILDS' },
  560. { exitCode: 1, output: 'ERR_PNPM_IGNORED_BUILDS', truncated: false, logPath: '/l', kind: 'build-blocked' }),
  561. pendingBuilds: ['native'],
  562. }))
  563. await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
  564. expect(state().install.failure).toEqual({ reason: 'ERR_PNPM_IGNORED_BUILDS', code: 'operation-error', kind: 'build-blocked', pendingBuilds: ['native'] })
  565. // The retry keeps the subject the check produced and carries the approved names under a new request id.
  566. face.approveBuildsAndRetry()
  567. const second = await started()
  568. expect(second).not.toBe(first)
  569. expect(plugins.installBundle).toHaveBeenLastCalledWith('x', { enabled: false, requestId: second, approvedBuilds: ['native'] })
  570. expect(state().install).toMatchObject({ subject: { spec: 'x', name: 'dsh-better-sidebar' }, failure: null })
  571. gates[1]!.resolve(ok({ ...APPLIED, bundle: 'dsh-better-sidebar', approvedBuilds: ['native'] }))
  572. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  573. expect(state().install).toMatchObject({ installed: 'dsh-better-sidebar', approvedBuilds: ['native'] })
  574. expect(plugins.installBundle).toHaveBeenCalledTimes(2)
  575. })
  576. it('drops an enable from the installed screen that settles after disposal', async () => {
  577. const enableGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  578. const { plugins, face, state, controller } = bench({ setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise) })
  579. await controller.load()
  580. face.openInstall()
  581. face.editInstallSpec('dsh-a')
  582. face.runInstall()
  583. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  584. face.enableInstalled()
  585. expect(plugins.setBundleEnabled).toHaveBeenCalledWith('dsh-new', true)
  586. const before = state()
  587. controller.dispose()
  588. enableGate.resolve(ok(APPLIED))
  589. await Promise.resolve()
  590. await Promise.resolve()
  591. await Promise.resolve()
  592. expect(state()).toBe(before)
  593. })
  594. it('settles an install while reads run beside it', async () => {
  595. const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  596. const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
  597. await controller.load()
  598. face.openInstall()
  599. face.editInstallSpec('pkg')
  600. face.runInstall()
  601. await started()
  602. // The Host announces the change before the run answers; the read it triggers must not drop the answer.
  603. await controller.load()
  604. gate.resolve(ok({ ...APPLIED, bundle: 'pkg' }))
  605. await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
  606. expect(plugins.listBundles).toHaveBeenCalledTimes(3)
  607. })
  608. it('keeps the Host words and kind of a failed install, and settles a run whose last chunk never came', async () => {
  609. // Every run waits on its own gate, so chunks can land while it is installing.
  610. const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
  611. const { face, state, controller, plugins, started } = bench({
  612. installBundle: vi.fn(() => {
  613. const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
  614. gates.push(gate)
  615. return gate.promise
  616. }),
  617. })
  618. const argv = ['pnpm', 'add', 'x']
  619. await controller.load()
  620. face.openInstall()
  621. face.editInstallSpec('x')
  622. let requestId = '' as PluginInstallRequestId
  623. const installing = async (): Promise<void> => {
  624. face.runInstall()
  625. requestId = await started()
  626. }
  627. const answer = (value: ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>): void => {
  628. gates[gates.length - 1]?.resolve(value)
  629. }
  630. // No chunk arrived: the Host's words are the reason, and there is no run; the kind is kept.
  631. await installing()
  632. answer(ok(failed({ code: 'operation-error', diagnostic: 'ERR_PNPM' }, { exitCode: 1, output: 'ERR_PNPM', truncated: false, logPath: '/l', kind: 'network' })))
  633. await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
  634. expect(state().install.runs).toEqual([])
  635. expect(state().install.failure).toEqual({ reason: 'ERR_PNPM', code: 'operation-error', kind: 'network' })
  636. expect(state().install.subject).toEqual({ spec: 'x', ...INSPECTED })
  637. // A refused answer, not a failed change, keeps the transport's words and settles the run without an exit code.
  638. await installing()
  639. controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stderr', text: 'streamed' })
  640. answer(refused('gateway/internal', 'offline'))
  641. await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: 'offline' }) })
  642. expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed', exitCode: null }])
  643. // A pnpm failure settles the open run with the code the answer names.
  644. await installing()
  645. controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'Done' })
  646. answer(ok(failed({ code: 'operation-error', diagnostic: 'tail' }, { exitCode: 7, output: 'tail', truncated: false, logPath: '/l', kind: 'unknown' })))
  647. await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
  648. expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'Done', exitCode: 7 }])
  649. // A failure after pnpm carries no package result: a run whose last chunk came keeps its own exit code, one still open settles without.
  650. await installing()
  651. controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'partial', exitCode: 0 })
  652. controller.appendLog({ requestId, jobId: 'k', argv, cwd: '/p', stream: 'stdout', text: 'open' })
  653. answer(ok(failed({ code: 'not-bundle' })))
  654. await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
  655. expect(state().install.runs).toEqual([
  656. { jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'partial', exitCode: 0 },
  657. { jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null },
  658. ])
  659. expect(state().install.failure).toEqual({ reason: '', code: 'not-bundle' })
  660. // A failure the Host does not explain has neither code nor words.
  661. await installing()
  662. answer(ok(failed()))
  663. await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
  664. expect(state().install.failure).toEqual({ reason: '' })
  665. expect(plugins.installBundle).toHaveBeenCalledTimes(5)
  666. // Editing the spec after a failure starts over too.
  667. face.editInstallSpec('y')
  668. expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null })
  669. })
  670. it('drops every late settlement after disposal', async () => {
  671. const enableGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  672. const installGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
  673. const { face, state, controller, started } = bench({
  674. setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise),
  675. installBundle: vi.fn().mockReturnValueOnce(installGate.promise),
  676. })
  677. await controller.load()
  678. face.openInstall()
  679. face.editInstallSpec('x')
  680. face.runInstall()
  681. await started()
  682. face.setEnabled(BUNDLE.name, true)
  683. const before = state()
  684. controller.dispose()
  685. enableGate.resolve(ok(APPLIED))
  686. installGate.resolve(ok(APPLIED))
  687. await Promise.resolve()
  688. await Promise.resolve()
  689. await Promise.resolve()
  690. expect(state()).toBe(before)
  691. await controller.load()
  692. expect(state()).toBe(before)
  693. face.setEnabled(BUNDLE.name, false)
  694. expect(state()).toBe(before)
  695. })
  696. it('drops a read that settles after disposal', async () => {
  697. const gate = deferred<ReturnType<typeof ok<{ entries: never[]; managementAvailable: boolean }>>>()
  698. const { state, controller } = bench({ inventory: vi.fn().mockReturnValueOnce(gate.promise) })
  699. const loading = controller.load()
  700. await Promise.resolve()
  701. const before = state()
  702. controller.dispose()
  703. gate.resolve(ok({ entries: [], managementAvailable: true }))
  704. await loading
  705. expect(state()).toBe(before)
  706. })
  707. it('drops a bundle read that settles after disposal', async () => {
  708. const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
  709. const { state, controller } = bench({ listBundles: vi.fn().mockReturnValueOnce(gate.promise) })
  710. const loading = controller.load()
  711. await vi.waitFor(() => { expect(state().status).toBe('loading') })
  712. await Promise.resolve()
  713. const before = state()
  714. controller.dispose()
  715. gate.resolve(ok([BUNDLE]))
  716. await loading
  717. expect(state()).toBe(before)
  718. })
  719. })