Files
deepseek-harness/apps/web/tests/plugin-config.e2e.ts
T
Yichen Jiang 90af3110b7 feat(web): host plugin configuration on the Plugins page
The Plugins page declares three slots as children of its main entry:
`plugins.item` lists an official plugin's page in the Official group,
`plugins.bundle.config` renders a bundle's own form on its page, and
`plugins.row.config` gives a row a configure control opening the row's
page. Every entry renders in the `summary` and `page` views the page
passes as owner props; the page draws the title, icon, and crumb and
projects the ledgers beside its store, naming no configurable plugin.

`ui-settings-plugins` registers its four host-plane pages into
`plugins.item` while the Host serves their namespaces and withdraws them
when it stops; the forms keep edits until a save and drop them on leaving
the page, so the discard control and the unsaved marker are gone. The
Settings section becomes the Built-in plugins shell around the inventory
tab, and `settings.plugin.item` is retired.

The Official group opens with the optional bundles the installation
ships, tagged Beta where the feature is one and without an official tag,
followed by the configuration pages. Auto review leaves the optional
bundles and the CLI's dependencies; the install guide names it as its
example spec. The version tag is a capsule like the other tags.

The `plugin-config` web lane drives the pages through the real wire and
opens a community fixture's row page after switching its bundle on; the
settings lanes re-record the renamed navigation entry.
2026-09-16 21:05:34 +08:00

261 lines
13 KiB
TypeScript

// Web e2e scenario: the configuration pages on the Plugins page — the official
// pages a deployment's exposed host-plane namespaces produce, one field edited
// through the real wire down to `$DSH_HOME/settings.yaml`, the override badge
// and reset that layering produces, and a community bundle's row configuration
// registered by its own browser half. Zero model calls: everything is client
// state plus the settings document and the profile on a blank frame, so there
// is no fixture and a stray stream would fail loud on the open llm seam.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-config', import.meta.url))
const OFFICIAL_EXPECTED = join(SNAPSHOT_DIR, 'official.expected.md')
const ROW_EXPECTED = join(SNAPSHOT_DIR, 'row.expected.md')
const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: plugin configuration pages', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
// The live-client fixture is a bundle with a browser half; switched on
// below, that half registers its row's configuration into the page.
scaffold = await launchWebScaffold({
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
})
browser = await chromium.launch()
// Chinese browser: the pages assert the localized copy the client derives
// from it, as the rest of the settings surface does.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Show the Plugins page's cards. The scenarios share one page so the
* settings document accumulates across them, so this closes any settings
* dialog a previous scenario left open and leaves whatever page it opened.
*/
async function openPlugins(): Promise<Locator> {
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
}
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
await panel.waitFor({ timeout: 10_000 })
while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) {
await panel.getByRole('button', { name: /^返回/ }).first().click()
}
await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 20_000 })
return panel
}
/** Open one official plugin's page from its card and wait for its form. */
async function openPage(panel: Locator, title: string): Promise<void> {
await panel.getByRole('button', { name: `查看 ${title}`, exact: true }).click()
await panel.locator('[data-plugin-config]').waitFor({ timeout: 10_000 })
}
/** The settings document as the Host has written it so far. */
async function settingsDocument(): Promise<string> {
return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '')
}
it('lists one official page per exposed host-plane namespace after the official bundles', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards'))
const panel = await openPlugins()
// Every page the shipped web composition exposes: the shell executor, the
// agent loop, subagent selection, and the DeepSeek search provider, after
// the two official bundles the installation ships switched off.
await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 })
const official = panel.locator('[data-plugin-group="official"]')
expect(await official.locator('[data-plugin-package]').count()).toBe(2)
expect(await official.locator('[data-plugin-item]').count()).toBe(4)
for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) {
expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
}
// A card carries the one-liner; the fields wait for the page.
expect(await official.getByText('限制 agent 运行的每一条命令。', { exact: true }).count()).toBe(1)
expect(await panel.getByLabel('命令超时(毫秒)').count()).toBe(0)
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(OFFICIAL_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('persists selected adapter routes as the subagent model allowlist', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
const panel = await openPlugins()
await openPage(panel, 'Subagent')
const toggle = panel.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
await toggle.click()
const models = panel.getByRole('group', { name: 'Agent 可选择的模型' })
await models.waitFor({ timeout: 10_000 })
const firstModel = models.getByRole('checkbox').first()
await firstModel.check()
const save = panel.getByRole('button', { name: '保存', exact: true })
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
.toBe(true)
expect(await settingsDocument()).toContain('enabled: true')
expect(await settingsDocument()).toContain('allowedModels:')
expect(await settingsDocument()).toContain('provider:')
expect(await settingsDocument()).toContain('model:')
// The page stays open once the save landed; a settled form offers no save to repeat.
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
await toggle.click()
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 })
.toBe(true)
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stages an edit and writes it only when saved', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
// The composed default this deployment ships, before any user layer.
expect(await timeout.inputValue()).toBe('60000')
await timeout.fill('12000')
await timeout.blur()
// Nothing crosses the wire until the user saves: leaving the control is
// not a decision to store the value.
expect(await settingsDocument()).not.toContain('timeoutMs')
const save = panel.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true)
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
.toBe(true)
// Presence in the user layer is what the badge reports, and the reset is
// offered only for a field that has one.
await expect.poll(() => panel.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
expect(await panel.getByRole('button', { name: '恢复默认' }).count()).toBe(1)
// A settled form offers no save to repeat.
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('drops a staged edit when the page is left, without touching the document', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-leave'))
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('7000')
await panel.getByRole('button', { name: '返回插件列表' }).click()
await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 10_000 })
await openPage(panel, '终端')
await expect.poll(() => panel.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('12000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('refuses to save a draft that is not a number', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid'))
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('soon')
const save = panel.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(await panel.getByText('请填数字;留空表示使用默认值。').count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('clears the field back to the composed default on reset', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-reset'))
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
expect(await timeout.inputValue()).toBe('12000')
// The reset stages the composed default; the document still carries the
// override until the save lands.
await panel.getByRole('button', { name: '恢复默认' }).click()
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
await panel.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
.toBe(false)
expect(await timeout.inputValue()).toBe('60000')
expect(await panel.getByText('已覆盖').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders a community bundle\'s row configuration, registered by its browser half, on the row\'s page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-row'))
const panel = await openPlugins()
// Off, the bundle's browser half is not loaded and the row has no configuration to open.
await panel.getByRole('button', { name: '查看 live-client', exact: true }).click()
const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' })
await row.waitFor({ timeout: 10_000 })
expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0)
// Switched on, the Host recomposes and the browser half mounts without a
// reload; its registration puts the configure control on the row.
await panel.getByRole('switch', { name: '启用 live-client' }).click()
const configure = panel.getByRole('button', { name: '配置 fixture-live-client' })
await configure.waitFor({ timeout: 30_000 })
await configure.click()
const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]')
await rowPage.waitFor({ timeout: 10_000 })
expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client')
expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1)
const form = rowPage.getByRole('form', { name: '动态插件配置' })
await form.getByLabel('问候语').fill('你好')
await form.getByRole('button', { name: '保存' }).click()
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.liveSaves), { timeout: 5_000 }).toBe('1')
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE)
await rowPage.getByRole('button', { name: '返回 live-client' }).click()
await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 })
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md'])
})
})