components.client.spec.tsx 36 KB

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