components.client.spec.tsx 46 KB

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