components.client.spec.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. // @vitest-environment jsdom
  2. import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
  5. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  7. import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
  8. import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx'
  9. import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
  10. import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
  11. afterEach(cleanup)
  12. const translate = (dict: typeof en): PluginManagerPageProps['t'] => ((key: PluginManagerLocaleKey, params?: Record<string, string>): string =>
  13. Object.entries(params ?? {}).reduce(
  14. (text, [name, value]) => text.replaceAll(`{${name}}`, value),
  15. dict[key],
  16. )) as PluginManagerPageProps['t']
  17. const t = translate(en)
  18. function pkg(overrides: Partial<PackageView> = {}): PackageView {
  19. return {
  20. name: 'dsh-better-sidebar',
  21. version: '0.16.0',
  22. installed: true,
  23. optional: false,
  24. enabled: true,
  25. rows: [],
  26. ...overrides,
  27. }
  28. }
  29. function row(overrides: Partial<PackageRow> = {}): PackageRow {
  30. return { entryId: 'include:sidebar' as PluginEntryId, rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active', ...overrides }
  31. }
  32. const IDLE_INSTALL: InstallState = {
  33. open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false,
  34. installed: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false,
  35. }
  36. const READY: PluginManagerState = {
  37. status: 'ready',
  38. packages: [],
  39. busy: [],
  40. notice: null,
  41. install: IDLE_INSTALL,
  42. confirm: null,
  43. highlight: null,
  44. }
  45. function renderTab(state: Partial<PluginManagerState> = {}) {
  46. const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
  47. const actions = {
  48. ensure: vi.fn(),
  49. refresh: vi.fn(),
  50. openInstall: vi.fn(),
  51. closeInstall: vi.fn(),
  52. editInstallSpec: vi.fn(),
  53. runInstall: vi.fn(),
  54. cancelInstall: vi.fn(),
  55. toggleInstallDetails: vi.fn(),
  56. approveBuildsAndRetry: vi.fn(),
  57. enableInstalled: vi.fn(),
  58. clearHighlight: vi.fn(),
  59. setEnabled: vi.fn(),
  60. uninstall: vi.fn(),
  61. confirm: vi.fn(),
  62. cancelConfirm: vi.fn(),
  63. setRowEnabled: vi.fn(),
  64. dismissNotice: vi.fn(),
  65. }
  66. const props = {
  67. t,
  68. ...actions,
  69. usePluginManager: bindSnapshotSelector(store),
  70. } as unknown as PluginManagerPageProps
  71. const { rerender } = render(<PluginManagerPage {...props} />)
  72. return {
  73. store,
  74. actions,
  75. set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
  76. setLanguage: (dict: typeof en) => { rerender(<PluginManagerPage {...props} t={translate(dict)} />) },
  77. }
  78. }
  79. describe('PluginManagerPage', () => {
  80. it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
  81. const { actions, set } = renderTab({ status: 'loading' })
  82. expect(actions.ensure).toHaveBeenCalledTimes(1)
  83. expect(screen.getByText(en.loading)).toBeTruthy()
  84. expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true)
  85. set({ status: 'unavailable' })
  86. expect(screen.getByRole('status').textContent).toBe(en.unavailable)
  87. set({ status: 'error' })
  88. expect(screen.getByRole('alert').textContent).toBe(en.error)
  89. fireEvent.click(screen.getByRole('button', { name: en.retry }))
  90. expect(actions.refresh).toHaveBeenCalledTimes(1)
  91. fireEvent.click(screen.getByRole('button', { name: en.refresh }))
  92. expect(actions.refresh).toHaveBeenCalledTimes(2)
  93. set({ status: 'ready' })
  94. expect(screen.getByText(en.empty)).toBeTruthy()
  95. fireEvent.click(screen.getByRole('button', { name: en.addPlugin }))
  96. expect(actions.openInstall).toHaveBeenCalledTimes(1)
  97. })
  98. it('lists the installed bundles as cards, the installation\'s offered ones as built in, and tags a problem the Host reports', () => {
  99. const { actions } = renderTab({
  100. packages: [
  101. pkg({ description: 'A sidebar.' }),
  102. pkg({ name: 'dsh-broken', enabled: false, error: { code: 'not-bundle' } }),
  103. pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }),
  104. pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }),
  105. pkg({ name: '@acme/dsh-tool', enabled: false }),
  106. // Selected by the profile but not a bundle: a problem the person can switch off, in the profile's own group.
  107. pkg({ name: 'dsh-selected', installed: false, error: { code: 'not-bundle' } }),
  108. pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false }),
  109. ],
  110. busy: ['dsh-protected'],
  111. })
  112. const cards = screen.getAllByRole('listitem')
  113. // The built-in group comes first.
  114. expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([
  115. '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected',
  116. ])
  117. expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem'])
  118. // Each group heads with its title and its bare count; the built-in card carries the official tag.
  119. expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy()
  120. expect(screen.getByRole('heading', { name: en.builtinTitle })).toBeTruthy()
  121. expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5'])
  122. expect(screen.getAllByText(en.statusOfficial)).toHaveLength(1)
  123. // A scoped name reads without its scope and harness prefix.
  124. expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false)
  125. expect(screen.getByText('A sidebar.')).toBeTruthy()
  126. expect(screen.getAllByText(en.statusProblem)).toHaveLength(2)
  127. // The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is.
  128. fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }))
  129. expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
  130. expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'broken') })).toHaveProperty('disabled', true)
  131. const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'protected') })
  132. expect(locked).toHaveProperty('disabled', true)
  133. expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired)
  134. })
  135. it('opens a built-in bundle\'s page with its official tag and no uninstall, and switches it on', () => {
  136. const { actions } = renderTab({
  137. packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })],
  138. })
  139. fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) }))
  140. const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
  141. expect(within(detail).getByText(en.statusOfficial)).toBeTruthy()
  142. expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull()
  143. fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) }))
  144. expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
  145. })
  146. it.each([
  147. ['agent-team-profile', 'builtinAgentTeamTitle', 'builtinAgentTeamDescription'],
  148. ['agent-team-web-profile', 'builtinAgentTeamWebTitle', 'builtinAgentTeamWebDescription'],
  149. ['auto-review', 'builtinAutoReviewTitle', 'builtinAutoReviewDescription'],
  150. ] as const)('localizes %s across cards, details, switches, and uninstall confirmation', (suffix, titleKey, descriptionKey) => {
  151. const name = `@deepseek-ai/dsh-experimental-${suffix}`
  152. const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, description: 'Original metadata.' })] })
  153. const assertCard = (dict: typeof en) => {
  154. expect(screen.getByRole('button', { name: dict.openDetail.replace('{name}', dict[titleKey]) }).textContent).toBe(dict[titleKey])
  155. expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
  156. expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
  157. expect(screen.queryByText('Original metadata.')).toBeNull()
  158. }
  159. assertCard(en)
  160. setLanguage(zh)
  161. assertCard(zh)
  162. fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', zh[titleKey]) }))
  163. expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false)
  164. fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', zh[titleKey]) }))
  165. for (const dict of [zh, en]) {
  166. setLanguage(dict)
  167. expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(dict[titleKey])
  168. expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
  169. expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
  170. expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
  171. expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', dict[titleKey]) })).toBeTruthy()
  172. }
  173. fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', en[titleKey]) }))
  174. expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith(name)
  175. set({ confirm: { action: 'uninstall', packageName: name } })
  176. for (const dict of [en, zh]) {
  177. setLanguage(dict)
  178. expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', dict[titleKey]) })).toBeTruthy()
  179. }
  180. })
  181. it('preserves metadata for another scope with the same short name', () => {
  182. const name = '@acme/dsh-experimental-agent-team-profile'
  183. const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] })
  184. setLanguage(zh)
  185. fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', 'experimental-agent-team-profile') }))
  186. expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('experimental-agent-team-profile')
  187. expect(screen.getByText('Third-party description.')).toBeTruthy()
  188. expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
  189. })
  190. it('opens a guide under the field and drops an example into it', () => {
  191. const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
  192. expect(screen.queryByText(en.installGuideIntro)).toBeNull()
  193. const toggle = screen.getByRole('button', { name: en.installGuideToggle })
  194. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  195. fireEvent.click(toggle)
  196. expect(screen.getByRole('button', { name: en.installGuideHide }).getAttribute('aria-expanded')).toBe('true')
  197. expect(screen.getByText(en.installGuideIdNote)).toBeTruthy()
  198. expect(screen.getByText(en.installGuideGitExample)).toBeTruthy()
  199. fireEvent.click(screen.getByRole('button', { name: en.installGuideFillAria.replace('{example}', en.installGuideIdExample) }))
  200. expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith(en.installGuideIdExample)
  201. fireEvent.click(screen.getByRole('button', { name: en.installGuideHide }))
  202. expect(screen.queryByText(en.installGuideIntro)).toBeNull()
  203. })
  204. it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => {
  205. const { actions, set } = renderTab({
  206. packages: [pkg({
  207. description: 'A sidebar.',
  208. rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })],
  209. })],
  210. })
  211. fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
  212. const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement
  213. expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('better-sidebar')
  214. // The version sits beside the name as a tag; the crumb only leads back.
  215. expect(within(detail).getByText('v0.16.0')).toBeTruthy()
  216. // The full package name stays visible under the short name.
  217. expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar')
  218. expect(within(detail).getByRole('button', { name: en.backToList }).textContent).toBe(en.crumbRoot)
  219. expect(within(detail).getByText('A sidebar.')).toBeTruthy()
  220. // The rows, in order, with their state and their module.
  221. const rows = within(detail).getAllByRole('listitem').filter(item => item.hasAttribute('data-plugin-row'))
  222. expect(rows.map(item => item.getAttribute('data-plugin-row'))).toEqual(['include:sidebar', 'include:theme'])
  223. expect(rows[1]?.getAttribute('data-state')).toBe('off')
  224. expect(within(detail).getByText(en.partsCountTotal.replace('{count}', '2'), { exact: false })).toBeTruthy()
  225. expect(within(detail).getByText('dsh-better-sidebar/theme')).toBeTruthy()
  226. expect(within(detail).getByText(en.rowPhaseActive)).toBeTruthy()
  227. expect(within(detail).getByText(en.partOff)).toBeTruthy()
  228. fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') }))
  229. expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
  230. fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }))
  231. expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
  232. // A problem and a protection the Host reports read on the page in the dictionary's words; the page leaves with the crumb.
  233. set({ packages: [pkg({ error: { code: 'operation-error', diagnostic: 'unreadable' }, readOnlyReason: 'management-required' })] })
  234. expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy()
  235. expect(within(detail).getByText(en.reasonManagementRequired)).toBeTruthy()
  236. expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })).toHaveProperty('disabled', true)
  237. expect(within(detail).getByText(en.partsEmpty)).toBeTruthy()
  238. set({ packages: [pkg({ error: { code: 'not-bundle' } })] })
  239. expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy()
  240. set({ packages: [pkg({ error: { code: 'operation-error' } })] })
  241. expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy()
  242. fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
  243. expect(document.querySelector('[data-plugin-detail]')).toBeNull()
  244. // A bundle without a description or a version says so; one that leaves the list drops back to the cards.
  245. const { version: _version, ...unversioned } = pkg()
  246. set({ packages: [unversioned] })
  247. fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
  248. expect(screen.getByText(en.noDescription)).toBeTruthy()
  249. expect(document.querySelector('[data-plugin-version]')).toBeNull()
  250. set({ packages: [] })
  251. expect(document.querySelector('[data-plugin-detail]')).toBeNull()
  252. expect(screen.getByText(en.empty)).toBeTruthy()
  253. })
  254. it('switches the rows of a bundle that is on, filters a long list, and locks what the Host will not address', () => {
  255. const rows = Array.from({ length: 12 }, (_row, index): PackageRow => {
  256. const live = row({
  257. rowId: `row-${String(index)}`, entryId: `include:row-${String(index)}` as PluginEntryId,
  258. ...index === 1 ? { readOnlyReason: 'unaddressable' as const } : {},
  259. ...index === 3 ? { phase: 'failed' as const } : {},
  260. ...index === 4 ? { phase: 'loading' as const } : {},
  261. })
  262. if (index !== 2) return live
  263. // The third row has no live entry: nothing to switch.
  264. const { entryId: _entryId, ...unmounted } = live
  265. return { ...unmounted, enabled: false, phase: null }
  266. })
  267. const { actions, set } = renderTab({ packages: [pkg({ rows })], busy: [rowKey('include:row-5')] })
  268. fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
  269. const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
  270. expect(within(detail).getByText(`${en.partsCountTotal.replace('{count}', '12')} · ${en.partsCountRunning.replace('{count}', '9')} · ${en.partsCountOff.replace('{count}', '1')} · ${en.partsCountFailed.replace('{count}', '1')}`)).toBeTruthy()
  271. fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') }))
  272. expect(actions.setRowEnabled).toHaveBeenCalledWith('include:row-0', false)
  273. // A protected row, a row without a live entry, and a row with a write in flight cannot be switched.
  274. const locked = within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-1') })
  275. expect(locked).toHaveProperty('disabled', true)
  276. expect(locked.getAttribute('title')).toBe(en.reasonUnaddressable)
  277. expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })).toHaveProperty('disabled', true)
  278. fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') }))
  279. expect(actions.setRowEnabled).toHaveBeenCalledTimes(1)
  280. expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-5') })).toHaveProperty('disabled', true)
  281. expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy()
  282. expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy()
  283. expect(document.querySelector('[data-plugin-row="include:row-3"]')?.getAttribute('data-state')).toBe('failed')
  284. // A long list gets a filter; nothing matching says so.
  285. const filter = within(detail).getByRole('searchbox', { name: en.partsFilter })
  286. fireEvent.change(filter, { target: { value: 'ROW-1' } })
  287. expect(within(detail).getAllByRole('listitem').filter(item => item.hasAttribute('data-plugin-row'))).toHaveLength(3)
  288. fireEvent.change(filter, { target: { value: 'nothing' } })
  289. expect(within(detail).getByText(en.partsFilterEmpty)).toBeTruthy()
  290. fireEvent.change(filter, { target: { value: '' } })
  291. // A bundle that is off shows its rows without switches.
  292. set({ packages: [pkg({ enabled: false, rows: rows.slice(0, 2).map(item => ({ ...item, enabled: false, phase: null })) })] })
  293. expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })).toBeNull()
  294. expect(within(detail).getAllByText(en.partOff)).toHaveLength(2)
  295. // A row without a fiber, on a bundle that is on, reads idle.
  296. set({ packages: [pkg({ rows: [row({ phase: null })] })] })
  297. expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy()
  298. })
  299. it('takes a spec, checks it, and words what the check refused', () => {
  300. const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
  301. expect(screen.getByText(en.installDescription)).toBeTruthy()
  302. const install = () => screen.getByRole('button', { name: en.installRun })
  303. expect(install()).toHaveProperty('disabled', true)
  304. const field = screen.getByRole('textbox', { name: en.installSpecLabel })
  305. fireEvent.change(field, { target: { value: 'dsh-x' } })
  306. expect(actions.editInstallSpec).toHaveBeenCalledWith('dsh-x')
  307. expect(document.querySelector('[data-terminal]')).toBeNull()
  308. expect(screen.queryByRole('checkbox')).toBeNull()
  309. set({ install: { ...IDLE_INSTALL, open: true, spec: ' dsh-x ' } })
  310. fireEvent.keyDown(screen.getByRole('textbox', { name: en.installSpecLabel }), { key: 'Enter' })
  311. fireEvent.click(install())
  312. expect(actions.runInstall).toHaveBeenCalledTimes(2)
  313. // The check keeps the field and the button inert.
  314. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'checking' } })
  315. expect(screen.getByRole('textbox', { name: en.installSpecLabel })).toHaveProperty('disabled', true)
  316. const checking = screen.getByRole('button', { name: en.installChecking })
  317. expect(checking).toHaveProperty('disabled', true)
  318. fireEvent.keyDown(screen.getByRole('textbox', { name: en.installSpecLabel }), { key: 'Enter' })
  319. expect(actions.runInstall).toHaveBeenCalledTimes(2)
  320. // Each refusal reads under the field.
  321. const problems: [string, string][] = [
  322. ['invalid-spec', en.installProblemInvalid.replace('{reason}', 'r')],
  323. ['already-installed', en.installProblemInstalled],
  324. ['not-found', en.installProblemNotFound],
  325. ['not-a-package', en.installProblemNotPackage],
  326. ['not-a-bundle', en.installProblemNotBundle.replace('{reason}', 'r')],
  327. ['network', en.installProblemNetwork],
  328. ['unknown', en.installProblemUnknown.replace('{reason}', 'r')],
  329. ]
  330. for (const [problem, sentence] of problems) {
  331. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', inputError: { problem: problem as never, reason: 'r' } } })
  332. expect(screen.getByRole('alert').textContent).toBe(sentence)
  333. expect(screen.getByRole('textbox', { name: en.installSpecLabel }).getAttribute('aria-invalid')).toBe('true')
  334. }
  335. fireEvent.click(screen.getByRole('button', { name: en.close }))
  336. expect(actions.closeInstall).toHaveBeenCalledTimes(1)
  337. })
  338. it('shows the subject while installing, folds the pnpm output behind the details, and stops through the Host', () => {
  339. const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true } as const
  340. const run = { jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/home/u/.dsh/profiles/web', output: 'Progress: resolved \x1b[96m1\x1b[39m\n' }
  341. const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run] } })
  342. expect(screen.getByRole('status').textContent).toBe(en.installingTitle)
  343. expect(screen.getByText('dsh-x')).toBeTruthy()
  344. expect(screen.getByText('A sidebar.')).toBeTruthy()
  345. expect(screen.getByText(en.installVersion.replace('{version}', '1.4.2'))).toBeTruthy()
  346. expect(screen.queryByRole('textbox')).toBeNull()
  347. // The output stays folded until asked for.
  348. expect(document.querySelector('[data-terminal]')).toBeNull()
  349. const details = screen.getByRole('button', { name: en.installDetailsShow })
  350. expect(details.getAttribute('aria-expanded')).toBe('false')
  351. fireEvent.click(details)
  352. expect(actions.toggleInstallDetails).toHaveBeenCalledTimes(1)
  353. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run], detailsOpen: true } })
  354. expect(screen.getByRole('button', { name: en.installDetailsHide }).getAttribute('aria-expanded')).toBe('true')
  355. expect(screen.getByText(en.installLocation.replace('{dir}', '/home/u/.dsh/profiles/web'))).toBeTruthy()
  356. // The run streams as a terminal: its command line, its coloured output so far, the running label.
  357. expect(screen.getByText('pnpm add dsh-x')).toBeTruthy()
  358. const terminal = document.querySelector('[data-terminal]') as HTMLElement
  359. expect(terminal.hasAttribute('data-running')).toBe(true)
  360. expect(within(terminal).getByText('1').getAttribute('style')).toContain('--dsw-static-blue-500')
  361. expect(within(terminal).getByText(en.terminalRunning)).toBeTruthy()
  362. // A long log folds its middle behind an expand control, so the dialog keeps its height while pnpm talks.
  363. const lines = Array.from({ length: 15 }, (_line, index) => `line ${String(index + 1)}`).join('\n')
  364. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true, runs: [{ ...run, output: `${lines}\n` }] } })
  365. expect(screen.queryByText('line 8')).toBeNull()
  366. fireEvent.click(screen.getByRole('button', { name: en.terminalExpandAria.replace('{n}', '3') }))
  367. expect(screen.getByText('line 8')).toBeTruthy()
  368. // Before the first chunk there is no location to name.
  369. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true } })
  370. expect(screen.getByText(en.terminalNoOutput)).toBeTruthy()
  371. // Cancel and the back control each ask the Host to stop the run; close waits for the Host's word.
  372. fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
  373. fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
  374. expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
  375. expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true)
  376. expect(actions.closeInstall).not.toHaveBeenCalled()
  377. })
  378. it('waits with the Host through starting, stopping, and applying, and words an unconfirmed stop', () => {
  379. const subject = { spec: 'slow', status: 'accepted', kind: 'registry', name: 'slow', bundle: true } as const
  380. const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'starting', subject } })
  381. // Before the Host acknowledges the run there is nothing to stop: cancel, back, and close all wait.
  382. expect(screen.getByRole('status').textContent).toBe(en.installStarting)
  383. expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
  384. expect(screen.getByRole('button', { name: en.installEditAria })).toHaveProperty('disabled', true)
  385. expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true)
  386. set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject } })
  387. fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
  388. fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
  389. expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
  390. // While the Host stops the run the terminal reads as cancelled rather than failed.
  391. set({
  392. install: {
  393. ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'cancelling', subject, detailsOpen: true,
  394. runs: [{ jobId: 'j', command: 'pnpm add slow', cwd: '/p', output: '', exitCode: null }],
  395. },
  396. })
  397. expect(screen.getByRole('status').textContent).toBe(en.installCancelling)
  398. expect(screen.getByRole('button', { name: en.installCancelling })).toHaveProperty('disabled', true)
  399. expect(within(document.querySelector('[data-terminal]') as HTMLElement).getByText(en.installCancelledShort)).toBeTruthy()
  400. set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'applying', subject } })
  401. expect(screen.getByRole('status').textContent).toBe(en.installApplying)
  402. expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
  403. // A stop the Host could not confirm says so over the running screen.
  404. set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { reason: 'offline', cancelUnconfirmed: true } } })
  405. expect(screen.getByRole('alert').textContent).toContain('offline')
  406. expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false)
  407. })
  408. it('offers to enable what a finished install added, and says when it waits for a restart', () => {
  409. const subject = { spec: '/plugins/dsh-x', status: 'accepted', kind: 'path', name: 'dsh-x', bundle: true } as const
  410. const { actions, set } = renderTab({
  411. install: {
  412. ...IDLE_INSTALL,
  413. open: true,
  414. spec: '/plugins/dsh-x',
  415. phase: 'done',
  416. subject,
  417. runs: [{ jobId: 'j1', command: 'pnpm add /plugins/dsh-x', cwd: '/p', output: 'Done in 1s\n', exitCode: 0 }],
  418. installed: 'dsh-x',
  419. },
  420. })
  421. expect(screen.getByText(en.installedTitle)).toBeTruthy()
  422. // A path without a description reads by its kind.
  423. expect(screen.getByText('dsh-x')).toBeTruthy()
  424. expect(screen.getByText(en.installSubjectPath)).toBeTruthy()
  425. expect(screen.queryByText(en.installDoneRestart)).toBeNull()
  426. // No way back to the spec from here; enabling is the one action.
  427. expect(screen.queryByRole('button', { name: en.installEditAria })).toBeNull()
  428. fireEvent.click(screen.getByRole('button', { name: en.installEnableNow }))
  429. expect(actions.enableInstalled).toHaveBeenCalledTimes(1)
  430. set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, enabling: true } })
  431. expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true)
  432. expect(screen.getByText(en.installDoneRestart)).toBeTruthy()
  433. // A run that named no bundle leaves only Done.
  434. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true } } })
  435. expect(screen.getByText(en.installDoneNothing)).toBeTruthy()
  436. expect(screen.queryByRole('button', { name: en.installEnableNow })).toBeNull()
  437. fireEvent.click(screen.getByRole('button', { name: en.installClose }))
  438. expect(actions.closeInstall).toHaveBeenCalledTimes(1)
  439. })
  440. it('asks to allow the scripts a blocked install left pending, retries with them, and says what was allowed', () => {
  441. const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true } as const
  442. const { actions, set } = renderTab({
  443. install: {
  444. ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', subject,
  445. failure: { reason: 'ERR', kind: 'build-blocked', pendingBuilds: ['native', '@scope/other'] },
  446. },
  447. })
  448. expect(screen.getByText(en.installFailureBuildBlocked)).toBeTruthy()
  449. const group = screen.getByRole('group', { name: en.installApprovalTitle })
  450. expect(within(group).getByText('native')).toBeTruthy()
  451. expect(within(group).getByText('@scope/other')).toBeTruthy()
  452. expect(within(group).getByText(en.installApprovalCaution)).toBeTruthy()
  453. // Plain retry would fail the same way, so only the approval is offered.
  454. expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull()
  455. fireEvent.click(within(group).getByRole('button', { name: en.installApproveAndRetry }))
  456. expect(actions.approveBuildsAndRetry).toHaveBeenCalledTimes(1)
  457. // Without the pending names the failure reads as the manual instruction, and plain retry is back.
  458. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', subject, failure: { reason: 'ERR', kind: 'build-blocked' } } })
  459. expect(screen.getByText(en.installFailureBuildBlockedManual)).toBeTruthy()
  460. expect(screen.queryByRole('group', { name: en.installApprovalTitle })).toBeNull()
  461. expect(screen.getByRole('button', { name: en.installRetry })).toBeTruthy()
  462. // The installed screen says which scripts were allowed.
  463. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject, installed: 'dsh-x', approvedBuilds: ['native'] } })
  464. expect(screen.getByText(en.installDoneApproved.replace('{names}', 'native'))).toBeTruthy()
  465. })
  466. it('words a failed install by its kind, else in the Host\'s words, and retries it', () => {
  467. const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null } as const
  468. const { actions, set } = renderTab({
  469. install: {
  470. ...IDLE_INSTALL,
  471. open: true,
  472. spec: 'github:a/b',
  473. phase: 'failed',
  474. subject,
  475. detailsOpen: true,
  476. runs: [{ jobId: 'j1', command: 'pnpm add github:a/b', cwd: '/p', output: 'ERR\n', exitCode: 1 }],
  477. failure: { reason: 'ERR\n', kind: 'network' },
  478. },
  479. })
  480. expect(screen.getByRole('alert').textContent).toBe(en.installFailedTitle)
  481. expect(screen.getByText(en.installFailureNetwork)).toBeTruthy()
  482. // A git spec without a manifest reads by its address and kind.
  483. expect(screen.getByText('github:a/b')).toBeTruthy()
  484. expect(screen.getByText(en.installSubjectGit)).toBeTruthy()
  485. expect(screen.getByText(en.terminalExitCode.replace('{code}', '1'))).toBeTruthy()
  486. fireEvent.click(screen.getByRole('button', { name: en.installRetry }))
  487. expect(actions.runInstall).toHaveBeenCalledTimes(1)
  488. fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
  489. expect(actions.cancelInstall).toHaveBeenCalledTimes(1)
  490. const kinds: [string, string][] = [
  491. ['pnpm-missing', en.installFailurePnpmMissing], ['timeout', en.installFailureTimeout],
  492. ['not-found', en.installFailureNotFound], ['no-matching-version', en.installFailureNoMatchingVersion],
  493. ['disk-full', en.installFailureDiskFull], ['permission', en.installFailurePermission],
  494. ['build-blocked', en.installFailureBuildBlockedManual], ['integrity', en.installFailureIntegrity], ['unknown', en.installFailureGeneric],
  495. ]
  496. for (const [kind, sentence] of kinds) {
  497. set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'r', kind: kind as never } } })
  498. expect(screen.getByText(sentence)).toBeTruthy()
  499. }
  500. // A failure without a kind reads by its code, else in the Host's words; without words, or without a failure at all, generically.
  501. set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '', code: 'not-bundle' } } })
  502. expect(screen.getByText(en.reasonNotBundle)).toBeTruthy()
  503. set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'ERR_PNPM_ADDING_TO_ROOT', code: 'operation-error' } } })
  504. expect(screen.getByText('ERR_PNPM_ADDING_TO_ROOT')).toBeTruthy()
  505. set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'the transport said so' } } })
  506. expect(screen.getByText('the transport said so')).toBeTruthy()
  507. set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '' } } })
  508. expect(screen.getByText(en.installFailureGeneric)).toBeTruthy()
  509. set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: null } })
  510. expect(screen.getByText(en.installFailureGeneric)).toBeTruthy()
  511. // A tarball spec reads by its kind too.
  512. set({ install: { ...IDLE_INSTALL, open: true, spec: '/p/x.tgz', phase: 'failed', subject: { spec: '/p/x.tgz', status: 'accepted', kind: 'tarball', bundle: null }, failure: null } })
  513. expect(screen.getByText(en.installSubjectTarball)).toBeTruthy()
  514. })
  515. it('scrolls to and marks the package an install enabled, then lets the mark go', () => {
  516. vi.useFakeTimers()
  517. const scrollIntoView = vi.fn()
  518. const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView')
  519. Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true })
  520. try {
  521. const { actions, set } = renderTab({ packages: [pkg()] })
  522. // A package the list does not show has nothing to scroll to; the mark still times out.
  523. set({ highlight: 'missing' })
  524. expect(scrollIntoView).not.toHaveBeenCalled()
  525. act(() => { vi.advanceTimersByTime(2_400) })
  526. expect(actions.clearHighlight).toHaveBeenCalledTimes(1)
  527. set({ highlight: 'dsh-better-sidebar' })
  528. expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(true)
  529. expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' })
  530. act(() => { vi.advanceTimersByTime(2_400) })
  531. expect(actions.clearHighlight).toHaveBeenCalledTimes(2)
  532. set({ highlight: null })
  533. expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(false)
  534. } finally {
  535. if (descriptor === undefined) delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
  536. else Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor)
  537. vi.useRealTimers()
  538. }
  539. })
  540. it('marks a card without a scrollIntoView to call', () => {
  541. vi.useFakeTimers()
  542. const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView')
  543. delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
  544. try {
  545. const { set } = renderTab({ packages: [pkg()] })
  546. set({ highlight: 'dsh-better-sidebar' })
  547. expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(true)
  548. } finally {
  549. if (descriptor !== undefined) Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor)
  550. vi.useRealTimers()
  551. }
  552. })
  553. it('confirms an uninstall by the package\'s title and runs the action through it', () => {
  554. const { actions, set } = renderTab({
  555. packages: [pkg(), pkg({ name: 'dsh-other' })],
  556. confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' },
  557. })
  558. expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'better-sidebar') })).toBeTruthy()
  559. expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy()
  560. fireEvent.click(screen.getByRole('button', { name: en.cancel }))
  561. expect(actions.cancelConfirm).toHaveBeenCalledTimes(1)
  562. set({ confirm: { action: 'uninstall', packageName: 'dsh-other' } })
  563. expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy()
  564. fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall }))
  565. expect(actions.confirm).toHaveBeenCalledTimes(1)
  566. })
  567. it('words every notice as a toast that dismisses itself', () => {
  568. vi.useFakeTimers()
  569. try {
  570. const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', seq: 1 } })
  571. expect(screen.getByRole('alert').textContent).toContain(en.restartNotice)
  572. set({ notice: { kind: 'overridden', packageName: 'pkg-1', seq: 2 } })
  573. expect(screen.getByRole('alert').textContent).toContain(en.overriddenNotice.replace('{name}', 'pkg-1'))
  574. set({ notice: { kind: 'cancelled', seq: 3 } })
  575. expect(screen.getByRole('alert').textContent).toContain(en.installCancelled)
  576. // A failure names what was being done; a refusal is worded by its code, a silent one generically.
  577. set({ notice: { kind: 'failed', action: 'enable', reason: 'the tree rejected it', packageName: 'pkg-1', seq: 4 } })
  578. expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', 'the tree rejected it'))
  579. set({ notice: { kind: 'failed', action: 'uninstall', code: 'bundle-in-use', reason: '', packageName: 'pkg-1', seq: 5 } })
  580. expect(screen.getByRole('alert').textContent).toContain(en.failedUninstall.replace('{reason}', en.reasonBundleInUse))
  581. set({ notice: { kind: 'failed', action: 'rowDisable', code: 'operation-error', reason: 'EACCES', packageName: 'pkg-1', seq: 6 } })
  582. expect(screen.getByRole('alert').textContent).toContain(en.failedRowDisable.replace('{reason}', 'EACCES'))
  583. set({ notice: { kind: 'failed', action: 'disable', reason: '', packageName: 'pkg-1', seq: 7 } })
  584. expect(screen.getByRole('alert').textContent).toContain(en.failedDisable.replace('{reason}', en.reasonOperationError))
  585. set({ notice: { kind: 'failed', action: 'rowEnable', reason: 'x', packageName: 'pkg-1', seq: 8 } })
  586. expect(screen.getByRole('alert').textContent).toContain(en.failedRowEnable.replace('{reason}', 'x'))
  587. // No button to press: the toast retires on its own and the store forgets it.
  588. expect(screen.queryByRole('button', { name: /got it/i })).toBeNull()
  589. expect(actions.dismissNotice).not.toHaveBeenCalled()
  590. // The hold grows with the text, up to eight seconds, then the fade.
  591. act(() => { vi.advanceTimersByTime(8_000 + 1_000) })
  592. expect(actions.dismissNotice).toHaveBeenCalledTimes(1)
  593. } finally {
  594. vi.useRealTimers()
  595. }
  596. })
  597. })