plugin-manager.e2e.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. // Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a
  2. // managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero
  3. // model calls: everything is client state plus the profile files and the settings
  4. // document, so there is no fixture and a stray stream would fail loud on the open llm seam.
  5. import { readFile } from 'node:fs/promises'
  6. import { fileURLToPath } from 'node:url'
  7. import type { Browser, Page } from 'playwright'
  8. import { chromium } from 'playwright'
  9. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  10. import { join } from 'node:path'
  11. import {
  12. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  13. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  14. } from './scaffold.ts'
  15. import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
  16. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
  17. const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
  18. const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md')
  19. const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
  20. const MODE = webSnapshotMode()
  21. describe('web e2e: plugin manager', () => {
  22. let scaffold: WebScaffold
  23. let browser: Browser
  24. let page: Page
  25. let tripwire: ReturnType<typeof watchConsole>
  26. beforeAll(async () => {
  27. scaffold = await launchWebScaffold({
  28. profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
  29. })
  30. browser = await chromium.launch()
  31. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  32. tripwire = watchConsole(page)
  33. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  34. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  35. }, 120_000)
  36. afterAll(async () => {
  37. await browser?.close()
  38. await scaffold?.close()
  39. })
  40. /** Close any open settings dialog, so the sidebar and the main column are clickable. */
  41. async function closeSettings() {
  42. if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
  43. await page.keyboard.press('Escape')
  44. await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
  45. }
  46. }
  47. /** Select the sidebar's Plugins entry and wait for the management page in the main column. */
  48. async function openPluginsPanel() {
  49. await closeSettings()
  50. await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
  51. const panel = page.locator('[data-plugin-panel]')
  52. await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
  53. return panel
  54. }
  55. /** One file under the harness home, or the empty string while it does not exist. */
  56. async function homeFile(...segments: string[]): Promise<string> {
  57. return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
  58. }
  59. it('lists the installed bundles with their switches and leaves the installation\'s own to Settings', async () => {
  60. onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
  61. const panel = await openPluginsPanel()
  62. await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 })
  63. const toggle = panel.getByRole('switch', { name: '启用 bundle' })
  64. expect(await toggle.getAttribute('aria-checked')).toBe('false')
  65. // The profile's own group holds its one bundle; the installation's optional bundles form the built-in
  66. // group, and its other bundles stay off the page.
  67. expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1)
  68. expect(await panel.locator('[data-plugin-group="builtin"] [data-plugin-package]').count()).toBe(3)
  69. // A bundle that is off still shows the rows its patch declares, without switches.
  70. await panel.getByRole('button', { name: '查看 bundle' }).click()
  71. await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
  72. expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
  73. await panel.getByRole('button', { name: '卸载 bundle' }).waitFor({ timeout: 5_000 })
  74. await panel.getByRole('button', { name: '返回插件列表' }).click()
  75. await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0)
  76. const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
  77. await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
  78. expect(tripwire.pageErrors).toEqual([])
  79. }, 60_000)
  80. it('updates built-in names and descriptions when the UI language changes', async () => {
  81. onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
  82. const panel = await openPluginsPanel()
  83. await panel.getByRole('button', { name: '查看 智能体团队(实验性)' }).click()
  84. const packageName = panel.locator('[data-plugin-name]')
  85. expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
  86. expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1)
  87. try {
  88. await page.getByRole('button', { name: '设置', exact: true }).click()
  89. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
  90. await page.getByRole('menuitem', { name: 'English' }).click()
  91. await page.getByRole('dialog', { name: 'Settings' }).waitFor()
  92. await page.keyboard.press('Escape')
  93. await panel.getByRole('heading', { name: 'Experimental Agent Teams', exact: true }).waitFor()
  94. expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
  95. expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1)
  96. await panel.getByRole('button', { name: 'Back to plugins' }).click()
  97. for (const title of ['Experimental Agent Teams', 'Experimental Agent Teams Web UI', 'Experimental Auto Authorization Review']) {
  98. await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
  99. expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1)
  100. }
  101. expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1)
  102. expect(await panel.getByText('Add an Auto review permission mode that uses the model to assess authorization before each tool call.').count()).toBe(1)
  103. } finally {
  104. if (await page.locator('html').getAttribute('lang') === 'en') {
  105. if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
  106. await page.getByRole('button', { name: 'Settings', exact: true }).click()
  107. }
  108. await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
  109. await page.getByRole('menuitem', { name: '中文' }).click()
  110. await page.getByRole('dialog', { name: '设置' }).waitFor()
  111. }
  112. await closeSettings()
  113. }
  114. await panel.getByRole('button', { name: '查看 智能体团队(实验性)', exact: true }).waitFor()
  115. expect(tripwire.pageErrors).toEqual([])
  116. }, 60_000)
  117. it('checks a spec before installing it and words what the check refused', async () => {
  118. onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
  119. const panel = await openPluginsPanel()
  120. await panel.getByRole('button', { name: '添加插件', exact: true }).click()
  121. const dialog = page.getByRole('dialog', { name: '添加插件' })
  122. await dialog.waitFor({ timeout: 10_000 })
  123. const field = dialog.getByRole('textbox', { name: '包名或地址' })
  124. const install = dialog.getByRole('button', { name: '安装', exact: true })
  125. expect(await install.isDisabled()).toBe(true)
  126. // A name the list already shows is refused without asking the Host.
  127. await field.fill('@fixture/bundle')
  128. await install.click()
  129. await dialog.getByRole('alert').waitFor({ timeout: 5_000 })
  130. expect(await dialog.getByRole('alert').textContent()).toBe('该插件已安装')
  131. // A path the Host cannot read as a package is refused with its reason, and the spec stays editable.
  132. await field.fill(join(scaffold.harnessHome, 'no-such-plugin'))
  133. await install.click()
  134. await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toBe('该路径不存在或不是有效的插件包')
  135. expect(await field.isDisabled()).toBe(false)
  136. // A name the registry would refuse never reaches it.
  137. await field.fill('Not A Package')
  138. await install.click()
  139. await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toContain('无法识别这个包名或地址')
  140. await dialog.getByRole('button', { name: '关闭' }).click()
  141. await expect.poll(() => page.getByRole('dialog', { name: '添加插件' }).count(), { timeout: 5_000 }).toBe(0)
  142. expect(tripwire.pageErrors).toEqual([])
  143. }, 60_000)
  144. it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => {
  145. onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
  146. const panel = await openPluginsPanel()
  147. const toggle = panel.getByRole('switch', { name: '启用 bundle' })
  148. await toggle.waitFor({ timeout: 20_000 })
  149. const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
  150. expect(mounted()?.fiber?.state).toBeUndefined()
  151. await toggle.click()
  152. const bundles = async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
  153. dsh: { profile: { bundles: string[] } }
  154. }).dsh.profile.bundles
  155. await expect.poll(bundles, { timeout: 10_000 }).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@fixture/bundle'])
  156. // A live profile: the row mounts once the whole tree recomposed, the switch is on, and nothing waits for a restart.
  157. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2)
  158. await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true')
  159. expect(await panel.getByText(/下次启动生效/).count()).toBe(0)
  160. const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
  161. await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
  162. // The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch.
  163. await panel.getByRole('button', { name: '查看 bundle' }).click()
  164. const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' })
  165. await rowSwitch.waitFor({ timeout: 10_000 })
  166. expect(await rowSwitch.getAttribute('aria-checked')).toBe('true')
  167. await rowSwitch.click()
  168. await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'cordis.patch.yml')).includes('fixture-row'), { timeout: 10_000 }).toBe(true)
  169. await expect.poll(() => rowSwitch.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false')
  170. // A disabled entry keeps its disposed fiber; only an active one counts as mounted.
  171. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2)
  172. await rowSwitch.click()
  173. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2)
  174. await panel.getByRole('button', { name: '返回插件列表' }).click()
  175. await toggle.click()
  176. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2)
  177. await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
  178. expect(tripwire.pageErrors).toEqual([])
  179. }, 90_000)
  180. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  181. expect(tripwire.warnings).toEqual([])
  182. await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md'])
  183. })
  184. })
  185. describe('web e2e: startup-applied plugin management', () => {
  186. it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => {
  187. const scaffold = await launchWebScaffold({
  188. profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
  189. })
  190. let browser: Browser | undefined
  191. try {
  192. browser = await chromium.launch()
  193. const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  194. const tripwire = watchConsole(page)
  195. onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-live'))
  196. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  197. await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
  198. const panel = page.locator('[data-plugin-panel]')
  199. const toggle = panel.getByRole('switch', { name: '启用 bundle' })
  200. await toggle.waitFor({ timeout: 20_000 })
  201. const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
  202. const bundles = async () => {
  203. const text = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8')
  204. return (JSON.parse(text) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles
  205. }
  206. expect(mounted()?.fiber?.state).toBeUndefined()
  207. await toggle.click()
  208. // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so.
  209. await expect.poll(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@fixture/bundle'])
  210. await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
  211. await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 })
  212. expect(mounted()?.fiber?.state).toBeUndefined()
  213. // The pack's page lists its rows from their declarations, with no live entry to switch.
  214. await panel.getByRole('button', { name: '查看 bundle' }).click()
  215. await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
  216. expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true)
  217. await panel.getByRole('button', { name: '返回插件列表' }).click()
  218. await toggle.click()
  219. await expect.poll(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
  220. await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
  221. expect(tripwire.pageErrors).toEqual([])
  222. } finally {
  223. await browser?.close()
  224. await scaffold.close()
  225. }
  226. }, 60_000)
  227. })