// Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a // managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero // model calls: everything is client state, seeded Session state, profile files, and the settings // document, so there is no fixture and a stray stream would fail loud on the open llm seam. import { mkdtemp, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { FiberState } from '@deepseek-ai/cordis' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { join } from 'node:path' import { SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, experimentalBundleDescription, openSettings, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md') const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md') const EXPORTS_EXPECTED = join(SNAPSHOT_DIR, 'exports.expected.md') const EXPORTS_EN_EXPECTED = join(SNAPSHOT_DIR, 'exports-en.expected.md') const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) const { version: badgeVersion } = JSON.parse(await readFile( new URL('../../../packages/experimental/badge-skill-bundle/package.json', import.meta.url), 'utf8', )) as { version: string } const MODE = webSnapshotMode() /** The profile manifest's bundles as the scaffold initializes them. */ const SCAFFOLD_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', SCAFFOLD_DEFAULTS_BUNDLE] describe('web e2e: plugin manager', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] }, }) browser = await chromium.launch() 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() }) /** Close any open settings dialog, so the sidebar and the main column are clickable. */ async function closeSettings() { 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) } } /** Change the UI language through Settings and close the dialog. */ async function setLanguage(language: 'en' | 'zh'): Promise { if (await page.locator('html').getAttribute('lang') === language) return const settings = language === 'en' ? '设置' : 'Settings' const source = language === 'en' ? '中文' : 'English' const target = language === 'en' ? 'English' : '中文' if (await page.getByRole('dialog', { name: settings }).count() === 0) { await openSettings(page, language === 'en' ? 'zh' : 'en') } await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click() await page.getByRole('menuitem', { name: target }).click() const dialog = page.getByRole('dialog', { name: language === 'en' ? 'Settings' : '设置' }) await dialog.waitFor() await page.keyboard.press('Escape') await expect.poll(() => dialog.count()).toBe(0) } /** Select the sidebar's Plugins entry and wait for the management page in the main column. */ async function openPluginsPanel() { await closeSettings() 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: 10_000 }) return panel } /** One file under the harness home, or the empty string while it does not exist. */ async function homeFile(...segments: string[]): Promise { return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') } it('keeps plugin header actions on one line as the available width changes', async () => { const facts: string[] = [] // A dedicated scaffold keeps the measured header deterministic: an install // task left by an earlier test swaps the toolbar's add control for the // view-installation button, which moves the wrap threshold. const headerScaffold = await launchWebScaffold() try { // Pin the extremes; at intermediate widths, the rendered font and locale // determine whether the two intrinsic items fit beside each other. Narrow // widths shrink the actions to icons before they wrap; the collapsed-bar case // pins those widths. const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside' } for (const language of ['en', 'zh'] as const) { const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE, reducedMotion: 'reduce', }) try { const probe = await context.newPage() const consoleWatch = watchConsole(probe) await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' }) const title = language === 'en' ? 'Plugins' : '插件' await probe.getByRole('button', { name: title, exact: true }).click() const panel = probe.locator('[data-plugin-panel]') const header = panel.locator('[data-plugin-bar] > header') await header.getByRole('heading', { name: title, exact: true }).waitFor() await probe.evaluate(() => document.fonts.ready) const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) await expect.poll(() => add.isEnabled()).toBe(true) await add.focus() const measure = () => header.evaluate((element) => { const heading = element.firstElementChild! const actions = element.querySelector('[data-plugin-toolbar]')! const titleRect = heading.querySelector('h1')!.getBoundingClientRect() const introRect = heading.querySelector('h1 + div')!.getBoundingClientRect() const headingRect = { right: Math.max(titleRect.right, introRect.right), bottom: Math.max(titleRect.bottom, introRect.bottom), width: Math.max(titleRect.right, introRect.right) - Math.min(titleRect.left, introRect.left), } const actionRect = actions.getBoundingClientRect() const headerRect = element.getBoundingClientRect() const intrinsicHeading = heading.cloneNode(true) as HTMLElement intrinsicHeading.querySelectorAll('[data-plugin-header-part]').forEach((part) => { part.removeAttribute('data-plugin-header-part') part.removeAttribute('style') }) Object.assign(intrinsicHeading.style, { display: 'block', position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none', }) element.append(intrinsicHeading) let intrinsicHeadingWidth: number try { intrinsicHeadingWidth = intrinsicHeading.getBoundingClientRect().width } finally { intrinsicHeading.remove() } const headerStyle = getComputedStyle(element) const contentWidth = headerRect.width - Number.parseFloat(headerStyle.paddingLeft) - Number.parseFloat(headerStyle.paddingRight) const fitsBeside = intrinsicHeadingWidth + actionRect.width + Number.parseFloat(headerStyle.columnGap) <= contentWidth + 1 const subtitle = heading.querySelector('h1 + div > span')! const range = document.createRange() range.selectNodeContents(subtitle) const lastText = Array.from(range.getClientRects()).at(-1)! const info = heading.querySelector('button')!.getBoundingClientRect() const buttons = Array.from(actions.querySelectorAll('button')) const singleLine = buttons.every((button) => { const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) const tops: number[] = [] while (walker.nextNode()) { if (!walker.currentNode.textContent?.trim()) continue range.selectNodeContents(walker.currentNode) for (const rect of range.getClientRects()) tops.push(rect.top) } return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 }) return { singleLine, fitsBeside, diagnostic: { flexWrap: headerStyle.flexWrap, headingMaxWidth: getComputedStyle(heading).maxWidth, introDisplay: getComputedStyle(heading.querySelector('h1 + div')!).display, intrinsicHeadingWidth, headingWidth: headingRect.width, actionsWidth: actionRect.width, contentWidth, gap: Number.parseFloat(headerStyle.columnGap), subtitleRight: lastText.right, infoLeft: info.left, }, placement: actionRect.left >= headingRect.right - 1 ? 'beside' as const : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 && info.top < lastText.bottom && info.bottom > lastText.top, focused: buttons.includes(document.activeElement as HTMLButtonElement), } }) const validLayout = (layout: Awaited>) => layout.singleLine && layout.contained && layout.infoFollowsText && layout.focused && layout.placement === (layout.fitsBeside ? 'beside' : 'below') for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { await probe.setViewportSize({ width, height: 1000 }) await expect.poll(async () => { const layout = await measure() return validLayout(layout) ? true : layout }, { message: `${language} ${width}px plugin header geometry` }).toBe(true) const { placement } = await measure() const pinned = PINNED_PLACEMENT[width] if (pinned !== undefined) expect(placement).toBe(pinned) facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`) } // Force less room than even the icon actions need beside the heading. await probe.setViewportSize({ width: 240, height: 1000 }) const previousWrap = await header.evaluate((element) => { const style = element.style const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') } element.parentElement!.removeAttribute('data-natural') style.setProperty('flex-wrap', 'nowrap') return previous }) try { const noWrap = await measure() expect(noWrap.fitsBeside).toBe(false) expect(noWrap.placement).not.toBe('below') expect(validLayout(noWrap)).toBe(false) } finally { await header.evaluate((element, previous) => { if (previous.value === '') element.style.removeProperty('flex-wrap') else element.style.setProperty('flex-wrap', previous.value, previous.priority) element.parentElement!.setAttribute('data-natural', '') }, previousWrap) } await probe.setViewportSize({ width: 390, height: 1000 }) await expect.poll(async () => validLayout(await measure())).toBe(true) const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) await menuTrigger.click() await probe.getByRole('menu').waitFor() await probe.keyboard.press('Escape') await expect.poll(() => probe.getByRole('menu').count()).toBe(0) expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true) expect(consoleWatch.pageErrors).toEqual([]) } finally { await context.close() } } await compareOrRefreshGolden( join(SNAPSHOT_DIR, 'header-responsive.expected.md'), `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes.\nAdd menu dismisses on Escape and returns focus in both locales.`, MODE, ) } finally { await headerScaffold.close() } }) it('uses icon actions before shrinking tabs and wraps only when both still cannot fit', async () => { const compactScaffold = await launchWebScaffold({ profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] }, }) const context = await browser.newContext({ viewport: { width: 1280, height: 480 }, locale: 'en-US', reducedMotion: 'reduce' }) try { const probe = await context.newPage() await probe.goto(compactScaffold.authenticatedUrl, { waitUntil: 'load' }) await probe.getByRole('button', { name: 'Plugins', exact: true }).click() const panel = probe.locator('[data-plugin-panel]') const bar = panel.locator('[data-plugin-bar]') const body = panel.locator('[data-plugin-body]') const refresh = bar.getByRole('button', { name: 'Refresh', exact: true }) const add = bar.getByRole('button', { name: 'Add plugin', exact: true }) await panel.locator('[data-plugin-package]').first().waitFor() await bar.getByRole('tablist').waitFor() await add.focus() for (const [width, icons, narrowTabs, wraps] of [ [1280, false, false, false], [544, true, false, false], [390, true, true, false], [320, true, true, true], [244, true, true, true], ] as const) { await probe.setViewportSize({ width, height: 480 }) await body.evaluate((element) => { element.scrollTop = 180 }) await probe.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact')) await expect.poll(() => bar.evaluate((element) => { const tablist = element.querySelector('[role="tablist"]')! const toolbar = element.querySelector('[data-plugin-toolbar]')! const action = toolbar.querySelector('button')! const tabRect = tablist.getBoundingClientRect(), actions = toolbar.getBoundingClientRect() const actionRect = action.getBoundingClientRect() const wrapped = actions.top >= tabRect.bottom return { icons: getComputedStyle(action.lastElementChild!).display === 'none', narrowTabs: tabRect.width < 240, wraps: wrapped, centered: wrapped || Math.abs(tabRect.top + tabRect.height / 2 - actionRect.top - actionRect.height / 2) < 1, } }), { message: `compact toolbar at ${width}px` }).toEqual({ icons, narrowTabs, wraps, centered: true }) expect(await add.evaluate(element => element === document.activeElement)).toBe(true) // The collapsed bar hides the title visually but keeps the page's heading for assistive technology. expect(await bar.getByRole('heading', { name: 'Plugins', level: 1, exact: true }).count()).toBe(1) } // A short list keeps its range even when the available travel cannot fully compact the tabs. await probe.setViewportSize({ width: 1280, height: 480 }) await body.evaluate((element) => { element.scrollTop = 0 }) await expect.poll(() => bar.evaluate(element => element.hasAttribute('data-compact'))).toBe(false) const range = await body.evaluate(element => element.scrollHeight - element.clientHeight) await probe.setViewportSize({ width: 1280, height: 480 + range - 30 }) await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(30) await probe.mouse.move(800, 400) await probe.mouse.wheel(0, 200) await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(30) expect(await body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(30) expect(await bar.evaluate(element => element.hasAttribute('data-compact'))).toBe(false) await probe.setViewportSize({ width: 390, height: 480 }) // Locator hover scrolls its target into view, and scrolling this list moves the header controls away from // the pointer; once the resized header stops moving, move the pointer onto each control instead. const pointAt = async (target: Locator): Promise => { await expect.poll(() => target.evaluate(element => new Promise((resolve) => { const before = element.getBoundingClientRect() requestAnimationFrame(() => requestAnimationFrame(() => { const after = element.getBoundingClientRect() resolve(before.x === after.x && before.y === after.y) })) }))).toBe(true) const bounds = await target.boundingBox() if (bounds === null) throw new Error('Header control is not visible') await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) } await pointAt(refresh) await probe.getByRole('tooltip', { name: 'Refresh', exact: true }).waitFor() await pointAt(add) await probe.getByRole('tooltip', { name: 'Add plugin', exact: true }).waitFor() await bar.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() await probe.getByRole('menu').waitFor() await probe.keyboard.press('Escape') await probe.getByRole('menu').waitFor({ state: 'hidden' }) await add.click() await probe.getByRole('dialog').waitFor() } finally { await context.close() await compactScaffold.close() } }) it('keeps the plugin page title and actions pinned while the cards scroll', async () => { await page.setViewportSize({ width: 1000, height: 480 }) const panel = await openPluginsPanel() const body = panel.locator('[data-plugin-body]') try { await panel.locator('[data-plugin-package]').first().waitFor() const headerTop = () => panel.locator('[data-plugin-bar] > header').evaluate(element => element.getBoundingClientRect().top) const bar = panel.locator('[data-plugin-bar]') const barHeight = () => bar.evaluate(element => element.getBoundingClientRect().height) const resting = await headerTop() const restingHeight = await barHeight() const refreshButton = bar.getByRole('button', { name: '刷新', exact: true }) const addButton = bar.getByRole('button', { name: '添加插件', exact: true }) const restingActions = await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()]) expect(resting).toBe(await panel.evaluate(element => element.getBoundingClientRect().top)) const restingBarBottom = await bar.evaluate(element => element.getBoundingClientRect().bottom) expect(await body.locator('[data-plugin-group]').first().evaluate(element => element.getBoundingClientRect().top)) .toBeCloseTo(restingBarBottom + 32, 1) await body.evaluate((element) => { element.scrollTop = element.scrollHeight }) const bottom = await body.evaluate(element => element.scrollTop) expect(bottom).toBeGreaterThan(100) expect(await headerTop()).toBe(resting) // The painted bar compacts while its document-flow height remains unchanged. await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true) expect(await barHeight()).toBe(restingHeight) expect(await bar.evaluate(element => getComputedStyle(element, '::after').borderBottomWidth)).not.toBe('0px') expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height))).toBe(64) expect(await body.evaluate(element => element.getBoundingClientRect().top)).toBe(resting) expect(await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()])) .toEqual(restingActions.map((bounds) => { if (bounds === null) throw new Error('Plugin toolbar action is not visible') return { ...bounds, y: bounds.y - 12 } })) // Reverse keyboard navigation scrolls each focused control into view below the bar, never under it. await panel.locator('[data-plugin-package] [role="switch"]').last().focus() const barBottom = await bar.evaluate(element => element.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(element, '::before').height)) const hidden: string[] = [] for (let step = 0; step < 30; step++) { await page.keyboard.press('Shift+Tab') const focus = await page.evaluate(() => { const element = document.activeElement const box = element?.getBoundingClientRect() return { inBody: element?.closest('[data-plugin-body]') !== null && element?.closest('[data-plugin-bar]') === null, top: box?.top ?? 0, name: element?.getAttribute('aria-label') ?? element?.textContent ?? '', } }) if (!focus.inBody) break if (focus.top < barBottom) hidden.push(`${focus.name} at ${String(focus.top)}`) } expect(hidden).toEqual([]) expect(await body.evaluate(element => element.scrollTop)).toBeLessThan(bottom) expect(await headerTop()).toBe(resting) await page.setViewportSize({ width: 1680, height: 480 }) await body.evaluate((element) => { element.scrollTop = element.scrollHeight }) await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true) await expect.poll(() => panel.evaluate((element) => { const bounds = element.getBoundingClientRect() const barElement = element.querySelector('[data-plugin-bar]')! const barBounds = barElement.getBoundingClientRect() const bodyBounds = element.querySelector('[data-plugin-body]')!.getBoundingClientRect() const dividerStyle = getComputedStyle(barElement, '::after') return { left: barBounds.left + Number.parseFloat(dividerStyle.left) - bounds.left, right: barBounds.right - Number.parseFloat(dividerStyle.right) - bounds.right, gap: bodyBounds.top - barBounds.top, } })).toEqual({ left: 0, right: 0, gap: 0 }) } finally { await body.evaluate((element) => { element.scrollTop = 0 }) await page.setViewportSize({ width: 1680, height: 1000 }) } }) it('aligns the first-read skeleton with the loaded plugin cards', async () => { const facts: string[] = [] let aria = '' const shots = MODE === 'refresh' ? await mkdtemp(join(tmpdir(), 'dsh-plugin-loading-')) : undefined for (const width of [1680, 1000]) { const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const release = Promise.withResolvers() const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager) const reads: ReturnType[] = [] const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles').mockImplementation(() => { const read = release.promise.then(() => listBundles()) reads.push(read) return read }) try { const probe = await context.newPage() const consoleWatch = watchConsole(probe) await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await probe.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() await expect.poll(() => spy.mock.calls.length, { timeout: 10_000 }).toBeGreaterThan(0) const panel = probe.locator('[data-plugin-panel]') const skeleton = panel.locator('[data-plugin-loading]') await skeleton.waitFor() await probe.evaluate(() => document.fonts.ready) expect(await skeleton.locator(':scope > ul > li').count()).toBe(4) expect(await skeleton.getAttribute('role')).toBe('status') expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…') expect(await panel.getAttribute('aria-busy')).toBe('true') const actions = panel.locator('[data-plugin-bar] > header > div:last-child button') expect(await actions.count()).toBe(3) for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true) expect(await panel.getByRole('button', { name: '插件说明' }).isEnabled()).toBe(true) const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd) if (aria === '') aria = loadingAria else expect(loadingAria).toBe(aria) const measure = (group: Locator, rowSelector = ':scope > ul > li') => group.evaluate((element, selector) => { const rect = (node: Element | null | undefined) => { if (node === null || node === undefined) throw new Error('Missing plugin layout element') const { x, y, width, height } = node.getBoundingClientRect() return { x, y, width, height } } return { pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector('[data-plugin-bar] > header')), groupHeader: rect(element.firstElementChild), rows: Array.from(element.querySelectorAll(selector)).slice(0, 4).map((row) => { const head = row.firstElementChild const main = head?.children[1] return { row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main), titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild), description: rect(main?.children[1]), } }), } }, rowSelector) const loading = await measure(skeleton) const blankActions = await skeleton.locator(':scope > ul > li > div > div:last-child').evaluateAll(nodes => nodes.map(node => ({ children: node.childElementCount, background: getComputedStyle(node).backgroundColor, width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height, }))) expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 58, height: 20 }))) if (shots !== undefined) { for (const colorScheme of ['light', 'dark'] as const) { await probe.emulateMedia({ colorScheme }) const path = join(shots, `loading-${width}-${colorScheme}.png`) await panel.screenshot({ path, animations: 'disabled' }) console.log(`Plugin loading screenshot: ${path}`) } } const motion = await skeleton.evaluate(element => element.getAnimations({ subtree: true }).map(animation => ({ duration: animation.effect?.getTiming().duration, opacityOnly: animation.effect instanceof KeyframeEffect && animation.effect.getKeyframes().every(frame => frame.opacity !== undefined && frame.transform === undefined), }))) expect(motion.length).toBe(13) expect(motion.every(animation => animation.duration === 2000 && animation.opacityOnly)).toBe(true) await probe.emulateMedia({ reducedMotion: 'reduce' }) await expect.poll(() => skeleton.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0) await probe.emulateMedia({ reducedMotion: 'no-preference', colorScheme: null }) release.resolve(undefined) await skeleton.waitFor({ state: 'detached' }) expect(await panel.getAttribute('aria-busy')).toBe('false') for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false) const basic = panel.locator('[data-plugin-group="basic"]') await basic.locator('[data-plugin-item]').first().waitFor() // Configuration cards lead the list; their absent switches give the text column more width. const loaded = await measure(basic) expect(await basic.locator(':scope > ul > li[data-plugin-item]').count()).toBe(4) expect(loaded.rows).toHaveLength(loading.rows.length) const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => { for (const axis of axes) { expect(Math.abs(a[axis] - b[axis]), `${width}px ${name}.${axis}: loading=${a[axis]}, loaded=${b[axis]}`).toBeLessThanOrEqual(0.1) } } compare('pageHeader', loading.pageHeader, loaded.pageHeader) compare('groupHeader', loading.groupHeader, loaded.groupHeader) for (const [index, row] of loading.rows.entries()) { const real = loaded.rows[index] if (real === undefined) break for (const part of ['row', 'head', 'icon'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part]) // Painted text bars are deliberately shorter than real copy; their line origins and heights align. for (const part of ['main', 'titleRow', 'title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height']) } facts.push(`${width}px: ${loaded.rows.length} rows; page/group headers, rows, icons and text line origins align within 0.1px`) expect(consoleWatch.pageErrors).toEqual([]) } finally { release.resolve(undefined) spy.mockRestore() try { await context.close() } finally { await Promise.allSettled(reads) } } } await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'loading.expected.md'), [ aria, '', ...facts, 'Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders', 'Loaded: skeleton removed; header actions enabled', 'Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses', ].join('\n'), MODE) }) it('delays refresh hints and keeps cards through manual refresh, failure, and retry', async () => { const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager) const reads: ReturnType[] = [] try { const probe = await context.newPage() const consoleWatch = watchConsole(probe) onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-refresh')) await probe.clock.install() await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() const panel = probe.locator('[data-plugin-panel]') const refresh = panel.getByRole('button', { name: '刷新', exact: true }) const add = panel.getByRole('button', { name: '添加插件', exact: true }) await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor() await expect.poll(() => refresh.isEnabled()).toBe(true) await probe.evaluate(() => document.fonts.ready) const cards = panel.locator('[data-plugin-package], [data-plugin-item]') const cardText = await cards.allTextContents() expect(cardText.length).toBeGreaterThan(0) const tooltip = probe.getByRole('tooltip').filter({ hasText: '刷新' }) const trace: string[] = [] const recordRefresh = async (phase: string) => { trace.push(phase, await refresh.ariaSnapshot(), JSON.stringify({ busy: await panel.getAttribute('aria-busy'), spinners: await refresh.locator('[data-state="ongoing"]').count(), cards: await cards.count(), skeletons: await panel.locator('[data-plugin-loading]').count(), alerts: await panel.getByRole('alert').allTextContents(), retry: await panel.getByRole('button', { name: '重试', exact: true }).count(), toasts: await probe.locator('body > [role="alert"]').allTextContents(), })) } const bounds = await refresh.boundingBox() if (bounds === null) throw new Error('Refresh button has no visible bounds') await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) try { await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) await probe.clock.runFor(499) expect(await tooltip.count()).toBe(0) trace.push(`Hover at 499ms: ${await tooltip.count()} tooltips`) await probe.clock.runFor(1) await tooltip.waitFor() trace.push('Hover at 500ms:', await tooltip.ariaSnapshot()) await probe.mouse.move(0, 999) await tooltip.waitFor({ state: 'detached' }) await add.focus() await probe.keyboard.press('Shift+Tab') expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true) await probe.clock.runFor(499) expect(await tooltip.count()).toBe(0) trace.push(`Focus at 499ms: ${await tooltip.count()} tooltips`) await probe.clock.runFor(1) await tooltip.waitFor() trace.push('Focus at 500ms:', await tooltip.ariaSnapshot()) await probe.keyboard.press('Tab') await tooltip.waitFor({ state: 'detached' }) } finally { await probe.clock.resume() } await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles') try { { // One failure round exercises the full path: kept cards and spinner while pending, // then the failure toast with no inline error over the stale cards. Success and retry // outcomes are the store's deterministic concern, covered by manager-store.client.spec.ts. const outcome = 'failure' as const const release = Promise.withResolvers() const called = spy.mock.calls.length spy.mockImplementationOnce(() => { const read = release.promise.then(() => { throw new Error('Fixture plugin refresh failed') }) reads.push(read) return read }) try { await refresh.click() await expect.poll(() => spy.mock.calls.length).toBeGreaterThan(called) expect(await refresh.isDisabled()).toBe(true) expect(await refresh.getAttribute('aria-busy')).toBe('true') expect(await panel.getAttribute('aria-busy')).toBe('true') const spinner = refresh.locator('[data-state="ongoing"]') await spinner.waitFor() expect(await spinner.evaluate(element => element.getAnimations({ subtree: true }).length)).toBeGreaterThan(0) expect(await cards.allTextContents()).toEqual(cardText) expect(await panel.locator('[data-plugin-loading]').count()).toBe(0) expect(await tooltip.count()).toBe(0) expect(await panel.getByRole('alert').count()).toBe(0) expect(await probe.locator('body > [role="alert"]').count()).toBe(0) await recordRefresh(`${outcome}: pending`) release.resolve(undefined) await probe.clock.runFor(400) await expect.poll(() => refresh.isEnabled()).toBe(true) expect(await refresh.getAttribute('aria-busy')).toBe('false') expect(await panel.getAttribute('aria-busy')).toBe('false') expect(await spinner.count()).toBe(0) expect(await cards.allTextContents()).toEqual(cardText) expect(await panel.locator('[data-plugin-loading]').count()).toBe(0) expect(await panel.getByRole('alert').count()).toBe(0) expect(await panel.getByRole('button', { name: '重试', exact: true }).count()).toBe(0) const toasts = probe.locator('body > [role="alert"]') await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试']) await recordRefresh(`${outcome}: settled`) } finally { release.resolve(undefined) } } } finally { spy.mockRestore() await probe.clock.resume() } await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'refresh.expected.md'), trace.join('\n'), MODE) expect(consoleWatch.pageErrors).toEqual([]) expect(consoleWatch.warnings).toEqual([]) } finally { try { await context.close() } finally { await Promise.allSettled(reads) } } }) it('keeps touch actions direct before keyboard focus and mouse hover', async () => { const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: ZH_BROWSER_LOCALE, }) try { const probe = await context.newPage() const input = await context.newCDPSession(probe) // Native touch input preserves desktop hover capability for mixed-input devices. const tap = async (target: Locator): Promise => { await target.scrollIntoViewIfNeeded() const bounds = await target.boundingBox() if (bounds === null) throw new Error('Touch target is not visible') await input.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }], }) await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } const consoleWatch = watchConsole(probe) onTestFailed(() => saveFailureShot(probe, 'web-e2e-touch-tooltips')) await probe.clock.install() await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await probe.getByRole('button', { name: '插件', exact: true }).click() const panel = probe.locator('[data-plugin-panel]') const refresh = panel.getByRole('button', { name: '刷新', exact: true }) const info = panel.getByRole('button', { name: '插件说明', exact: true }) await expect.poll(() => refresh.isEnabled()).toBe(true) await probe.setViewportSize({ width: 390, height: 844 }) await probe.evaluate(() => document.fonts.ready) const calls = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles') try { await tap(refresh) await expect.poll(() => calls.mock.calls.length).toBe(1) await expect.poll(() => refresh.isEnabled()).toBe(true) } finally { calls.mockRestore() } await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) await probe.clock.runFor(500) expect(await probe.getByRole('tooltip').count()).toBe(0) const facts = ['Touch refresh: one refresh request; no tooltip'] await tap(info) const help = probe.getByRole('tooltip') await help.waitFor() const bounds = await help.boundingBox() if (bounds === null) throw new Error('Tapped plugin explanation is not visible') expect(bounds.x).toBeGreaterThanOrEqual(12) expect(bounds.x + bounds.width).toBeLessThanOrEqual(390 - 12) expect(bounds.y).toBeGreaterThanOrEqual(12) expect(bounds.y + bounds.height).toBeLessThanOrEqual(844 - 12) facts.push('Touch explanation: visible within the 390px viewport', await help.ariaSnapshot()) await tap(info) expect(await help.count()).toBe(0) await tap(info) await help.waitFor() await tap(panel.getByRole('heading', { name: '插件', exact: true })) expect(await help.count()).toBe(0) facts.push('Explanation closes on another tap or an outside touch') await probe.keyboard.press('Tab') await refresh.focus() await probe.clock.runFor(500) await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor() facts.push('Keyboard focus after touch: refresh tooltip visible') await probe.keyboard.press('Tab') expect(await probe.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(true) await refresh.hover() await probe.clock.runFor(500) await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor() facts.push('Mouse hover after touch: refresh tooltip visible') await probe.mouse.move(0, 843) // Tab moved focus to the icon-only Add plugin button, whose focus tooltip stays; the hovered ones leave. expect(await probe.getByRole('tooltip').filter({ hasText: '刷新' }).count()).toBe(0) expect(await probe.getByRole('tooltip').filter({ hasText: '在这里配置官方插件' }).count()).toBe(0) await input.detach() await probe.clock.resume() await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'), ['# Touch tooltips', '', ...facts].join('\n'), MODE) expect(consoleWatch.pageErrors).toEqual([]) } finally { await context.close() } }) it('starts with an unavailable selected bundle and lets the user clear its error', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle')) const panel = await openPluginsPanel() await panel.getByRole('button', { name: '查看 @fixture/missing-bundle', exact: true }).click() const toggle = panel.getByRole('switch', { name: '启用 @fixture/missing-bundle', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('true') expect(await toggle.isDisabled()).toBe(false) await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 }) expect((await scaffold.ctx.pluginManager.listBundles()).find(row => row.name === '@fixture/missing-bundle')) .toMatchObject({ enabled: true, installed: false, removable: true, error: { code: 'operation-error' }, rows: [] }) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'missing-bundle.expected.md'), await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[scaffold.harnessHome, '{{home}}']], }), MODE) await toggle.click() await expect.poll(async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles, { timeout: 10_000 }).toEqual(SCAFFOLD_BUNDLES) await expect.poll(() => panel.getByText(/cannot resolve profile bundle/).count(), { timeout: 10_000 }).toBe(0) expect((await scaffold.ctx.pluginManager.listBundles()).some(row => row.name === '@fixture/missing-bundle')).toBe(false) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('lists the installed bundles with their switches and leaves the installation\'s own to Settings', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list')) const panel = await openPluginsPanel() const info = panel.getByRole('button', { name: '插件说明' }) const infoBounds = await info.boundingBox() const iconBounds = await info.locator('svg').boundingBox() const subtitleBounds = await panel.getByText('安装、启用和配置插件', { exact: true }).boundingBox() if (infoBounds === null || iconBounds === null || subtitleBounds === null) { throw new Error('Plugin information button, icon, and subtitle must be visible') } expect(infoBounds.width).toBe(20) expect(infoBounds.height).toBe(20) expect(iconBounds.width).toBe(11) expect(iconBounds.height).toBe(11) expect(infoBounds.x - subtitleBounds.x - subtitleBounds.width).toBeCloseTo(4) expect(iconBounds.y + iconBounds.height / 2).toBeCloseTo(subtitleBounds.y + subtitleBounds.height / 2) await info.hover() const hoverHelp = page.getByRole('tooltip') await hoverHelp.waitFor() expect(await hoverHelp.textContent()).toBe('在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看') await panel.getByRole('heading', { name: '插件', exact: true }).hover() await hoverHelp.waitFor({ state: 'hidden' }) await info.focus() await page.keyboard.press('Enter') const help = page.getByRole('tooltip') await help.waitFor() expect(await help.textContent()).toContain('设置 → 内置插件') await page.keyboard.press('Escape') expect(await help.count()).toBe(0) expect(await info.evaluate(element => element === document.activeElement)).toBe(true) await info.click() await panel.getByRole('heading', { name: '插件', exact: true }).hover() await help.hover() expect(await help.isVisible()).toBe(true) await panel.getByRole('heading', { name: '插件', exact: true }).click() expect(await help.count()).toBe(0) await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 }) const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') const card = panel.locator('[data-plugin-package="@fixture/bundle"]') const open = card.getByRole('button', { name: '查看 @fixture/bundle', exact: true }) await open.hover() const hoverRadius = await card.evaluate(element => getComputedStyle(element).borderRadius) await toggle.focus() await page.keyboard.press('Shift+Tab') expect(await open.evaluate(element => element.matches(':focus-visible'))).toBe(true) const focusRing = await open.evaluate((element) => { const style = getComputedStyle(element, '::after') return { radius: style.borderRadius, cardRadius: style.getPropertyValue('--dsw-radius-xl').trim(), outlineStyle: style.outlineStyle, outlineWidth: Number.parseFloat(style.outlineWidth), } }) expect(focusRing.radius).toBe(hoverRadius) expect(focusRing.radius).toBe(focusRing.cardRadius) expect(focusRing.outlineStyle).toBe('solid') expect(focusRing.outlineWidth).toBeGreaterThan(0) // Profile bundles stay in Installed; configuration pages and offered bundles have their own groups. expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) expect(await panel.locator('[data-plugin-group="basic"] [data-plugin-item]').count()).toBe(4) expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-inspector"]').count()).toBe(0) expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false') expect(await panel.getByRole('switch', { name: '启用 自定义插件索引', exact: true }).getAttribute('aria-checked')).toBe('false') expect(await panel.getByRole('switch', { name: '启用 开发者工具', exact: true }).count()).toBe(0) expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0) // A bundle the installation ships but the profile runs no row of stays out of both lists: the // LiveShare card appears only once its own plugins run (covered by the visibility scenario below). expect(await panel.locator('[data-plugin-group="extensions"] [data-plugin-package="@deepseek-ai/dsh-experimental-liveshare"]').count()).toBe(0) await panel.getByRole('button', { name: '更多', exact: true }).click() await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() expect(await panel.getByText(experimentalBundleDescription('inspector-profile', 'zh'), { exact: true }).count()).toBe(1) const bundles = await scaffold.ctx.pluginManager.listBundles() expect(bundles.some(bundle => bundle.name === '@deepseek-ai/dsh-experimental-terminal-bundle')).toBe(true) expect(await panel.getByRole('switch', { name: '启用 持久终端', exact: true }).count()).toBe(0) // The More list also omits LiveShare until its rows run, so the offered count is one lower than // the eligible installed optional-bundle list. expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-experimental-liveshare"]').count()).toBe(0) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) .toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle' && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) expect(bundles.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) .toMatchObject({ enabled: false, optional: false, availability: 'missing', listedByDefault: false }) expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)) .toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false }) expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0) } const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all() expect(await Promise.all(cards.map(async card => [ await card.getAttribute('data-plugin-package'), await card.getByText('实验性', { exact: true }).count(), ]))).toEqual([ ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], ['@deepseek-ai/dsh-experimental-auto-review', 1], ['@deepseek-ai/dsh-experimental-forge-bundle', 1], ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], ['@deepseek-ai/dsh-experimental-tool-worktree', 1], ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1], ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1], ['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1], ['@deepseek-ai/dsh-subagent-codex', 0], ['@deepseek-ai/dsh-subagent-claude-code', 0], ]) expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0) await panel.getByRole('button', { name: '返回插件', exact: true }).click() expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0) await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 }) await panel.getByRole('button', { name: '返回插件' }).click() await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0) const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('enables both default-hidden shipped bundles by name without adding a profile dependency', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-bundled-enable')) const profileDir = join(scaffold.harnessHome, 'profiles', 'scaffold') const before = readProfileManifest('dsh', profileDir).dependencies for (const [name, title] of [ ['@deepseek-ai/dsh-experimental-session-search', '会话搜索'], ['@deepseek-ai/dsh-experimental-ralph-bundle', 'Ralph 循环'], ] as const) { const panel = await openPluginsPanel() expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0) await panel.getByRole('button', { name: '添加插件', exact: true }).click() const dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill(name) await dialog.getByRole('button', { name: '安装', exact: true }).click() const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true }) await ready.waitFor() expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false) expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1) await ready.getByRole('button', { name: '立即启用', exact: true }).click() const card = panel.locator(`[data-plugin-package="${name}"]`) await card.waitFor() expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(true) expect(readProfileManifest('dsh', profileDir).dependencies).toEqual(before) await card.getByRole('switch', { name: `启用 ${title}`, exact: true }).click() await card.waitFor({ state: 'hidden' }) expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('keeps off-state switch thumbs light in both themes', async () => { // The off-state thumb must stay light so an operable toggle cannot read as // disabled, whose only other difference is reduced opacity. for (const [theme, label] of [['light', '浅色'], ['dark', '深色']] as const) { await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置', exact: true }) await settings.getByRole('button', { name: '通用设置', exact: true }).click() await settings.getByRole('button', { name: label, exact: true }).click() await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(theme === 'dark') const panel = await openPluginsPanel() const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle', exact: true }) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') const appearance = await toggle.evaluate((element) => { const thumb = element.firstElementChild if (thumb === null) throw new Error('Switch thumb is missing') const bounds = element.getBoundingClientRect() return { width: bounds.width, height: bounds.height, thumb: getComputedStyle(thumb).backgroundColor, opacity: getComputedStyle(element).opacity, } }) expect(appearance).toEqual({ width: 36, height: 20, thumb: theme === 'dark' ? 'rgb(173, 178, 184)' : 'rgb(255, 255, 255)', opacity: '1', }) } // Leave the shared page in the default theme for the tests after this one. await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置', exact: true }) await settings.getByRole('button', { name: '通用设置', exact: true }).click() await settings.getByRole('button', { name: '浅色', exact: true }).click() await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(false) await closeSettings() }) it('prefers manifest icons for packages and reads exported icons for subpath plugins', async () => { const panel = await openPluginsPanel() onTestFinished(closeSettings) const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` const fallbackIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/fallback-icon.svg'))).toString('base64')}` const voiceIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle/icon.svg', import.meta.url)))).toString('base64')}` const images: string[] = [] const checkImage = async (selector: string, source: string, label: string) => { const image = panel.locator(`${selector} img`).first() await image.waitFor() expect(await image.getAttribute('src')).toBe(source) await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) const size = await image.evaluate((node: HTMLImageElement) => ({ width: node.width, height: node.height, naturalWidth: node.naturalWidth, })) expect(size.naturalWidth).toBeGreaterThan(0) images.push(`${label}: image, ${size.width}×${size.height}, decoded`) } await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]') expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]', voiceIcon, 'Disabled Voice Input card') try { for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36) if (MODE === 'refresh') { const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url)) await page.screenshot({ path }) console.log(`Plugin icon screenshot: ${path}`) } } } finally { await page.emulateMedia({ colorScheme: null }) } await panel.getByRole('button', { name: '查看 语音输入', exact: true }).click() await checkImage('[data-plugin-detail]', voiceIcon, 'Voice Input detail') await panel.getByRole('button', { name: '返回插件' }).click() await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') await checkImage('[data-plugin-row="fixture-search"]', fallbackIcon, 'Independent search row') expect(await panel.locator('[data-plugin-row="fixture-review"] img').count()).toBe(0) expect(await panel.locator('[data-plugin-row="fixture-review"] svg').count()).toBeGreaterThan(0) images.push('Independent review row: generic artwork') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) await panel.getByRole('button', { name: '返回插件' }).click() expect(tripwire.pageErrors).toEqual([]) }) it('shows a bundle detail document image before activation and leaves other plugin details unchanged', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preview')) const panel = await openPluginsPanel() await panel.getByRole('button', { name: '更多', exact: true }).click() await panel.getByRole('button', { name: '查看 DSH 徽章技能', exact: true }).click() const detail = panel.locator('[data-plugin-detail]') const preview = detail.locator('[data-detail-document]').getByRole('img', { name: 'DSH 徽章技能功能预览', exact: true }) await preview.waitFor() await preview.evaluate(async (image: HTMLImageElement) => { await image.decode() }) expect(await preview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640) expect(await detail.getByRole('switch').getAttribute('aria-checked')).toBe('false') const fits = await preview.evaluate((image: HTMLImageElement) => { const bounds = image.getBoundingClientRect() const parent = image.parentElement!.getBoundingClientRect() return bounds.width > 0 && bounds.height > 0 && bounds.left >= parent.left && bounds.right <= parent.right + 1 }) expect(fits).toBe(true) await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor() await detail.getByText(badgeVersion, { exact: true }).waitFor() await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[badgeVersion, '{{version}}']], }), MODE) const chineseSource = await preview.getAttribute('src') try { await setLanguage('en') const englishPreview = detail.locator('[data-detail-document]') .getByRole('img', { name: 'DSH badge skill feature preview', exact: true }) await englishPreview.waitFor() await englishPreview.evaluate(async (image: HTMLImageElement) => { await image.decode() }) expect(await englishPreview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640) expect(await englishPreview.getAttribute('src')).not.toBe(chineseSource) } finally { await setLanguage('zh') } expect(await preview.getAttribute('src')).toBe(chineseSource) await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() await panel.getByRole('button', { name: '返回插件', exact: true }).click() await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() // A bundle without meta.detail still shows its README; this one has no images. await panel.locator('[data-detail-document] h1').waitFor() expect(await panel.getByRole('img').count()).toBe(0) await panel.getByRole('button', { name: '返回插件', exact: true }).click() expect(tripwire.pageErrors).toEqual([]) }) it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) const panel = await openPluginsPanel() await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' }) const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' }) await search.getByText('文件搜索', { exact: true }).waitFor() await review.getByText('代码审查', { exact: true }).waitFor() expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1) expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1) await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor() // The README detail document loads asynchronously after the description. const readmeHeading = panel.locator('[data-detail-document]').getByRole('heading', { name: 'Fixture bundle', exact: true }) await readmeHeading.waitFor() expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false) await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], }), MODE) try { await setLanguage('en') await search.getByText('File Search', { exact: true }).waitFor() expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0) expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(0) expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0) await readmeHeading.waitFor() await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], }), MODE) expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle') } finally { await setLanguage('zh') } await panel.getByRole('button', { name: '返回插件' }).click() expect(tripwire.pageErrors).toEqual([]) }) it('updates built-in names and descriptions when the UI language changes', async () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) const panel = await openPluginsPanel() await panel.getByRole('button', { name: '查看 语音输入', exact: true }).click() const packageName = panel.locator('[data-plugin-name]') expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-voice-input-bundle') expect(await panel.getByText('在本机使用 SenseVoice 转写录音,首次使用需安装依赖。').count()).toBe(1) try { await setLanguage('en') await panel.getByRole('heading', { name: 'Voice input', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-voice-input-bundle') expect(await panel.getByText('Transcribe recordings locally with SenseVoice; first use requires installing dependencies').count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() await panel.getByRole('button', { name: 'View Voice input', exact: true }).waitFor() expect(await panel.getByRole('switch', { name: 'Enable Voice input', exact: true }).count()).toBe(1) // Schedule ships in the delivered composition, so no bundle card carries it in either language. expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0) // The official configuration pages follow the language too, from their own dictionary. for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) { await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() } } finally { await setLanguage('zh') } await panel.getByRole('button', { name: '查看 语音输入', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('provides Team mode per Session without an experimental plugin switch', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-team')) onTestFinished(closeSettings) const panel = await openPluginsPanel() expect(await panel.getByRole('switch', { name: '启用 智能体团队', exact: true }).count()).toBe(0) expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]').count()).toBe(0) expect([...scaffold.ctx.loader.entries()].filter(entry => ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id) && entry.fiber?.state === FiberState.ACTIVE)).toHaveLength(3) const teamPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const teamTripwire = watchConsole(teamPage) try { await teamPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspaceZh(teamPage, scaffold.workspaceCwd) await teamPage.getByRole('button', { name: /^选择模型/u }).click() const mode = teamPage.getByRole('switch', { name: '使用智能体团队', exact: true }) await mode.waitFor() expect(await mode.getAttribute('aria-checked')).toBe('false') await mode.click() await expect.poll(() => mode.getAttribute('aria-checked')).toBe('true') expect(teamTripwire.pageErrors).toEqual([]) expect(teamTripwire.warnings).toEqual([]) } finally { await teamPage.close() } }, 60_000) it('mounts the delivered Automation tasks rows without a bundle switch', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-schedule')) const panel = await openPluginsPanel() const scheduleRows = () => [...scaffold.ctx.loader.entries()] .filter(entry => ['schedule', 'ui-schedule'].includes(entry.options.id)) // The delivered Web composition ships both rows enabled, so no bundle card // offers a switch for them. expect(scheduleRows()).toHaveLength(2) expect(scheduleRows().every(entry => entry.fiber?.state === FiberState.ACTIVE)).toBe(true) expect(await panel.getByRole('button', { name: '查看 自动化任务', exact: true }).count()).toBe(0) expect(await panel.getByRole('switch', { name: '启用 自动化任务', exact: true }).count()).toBe(0) await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '自动化任务', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('keeps IME confirmation Enter in install fields and submits only a plain Enter', async () => { const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const manager = scaffold.ctx.pluginManager const manifest = await homeFile('profiles', 'scaffold', 'package.json') const registrySpy = vi.spyOn(manager, 'registries').mockResolvedValue({ registry: null, fallbackRegistries: [], resolved: 'https://registry.npmjs.org/', }) const inspectSpy = vi.spyOn(manager, 'inspect').mockImplementation(async (spec, options) => ({ status: 'accepted', kind: 'registry', name: spec, version: '1.0.0', bundle: true, registry: options?.registry ?? null, })) const installSpy = vi.spyOn(manager, 'installBundle').mockImplementation(async spec => ({ changed: false, application: 'failed', stage: 'install', target: spec, error: { code: 'operation-error', diagnostic: 'IME fixture: no package was installed' }, })) try { const probe = await context.newPage() const consoleWatch = watchConsole(probe) onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-ime-enter')) await probe.clock.install() await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() const panel = probe.locator('[data-plugin-panel]') await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 }) const trace: string[] = ['Synthetic browser KeyboardEvents, not an OS input-method test.'] for (const target of ['package', 'custom registry'] as const) { inspectSpy.mockClear() installSpy.mockClear() await panel.getByRole('button', { name: '添加插件', exact: true }).click() const dialog = probe.getByRole('dialog', { name: '添加插件', exact: true }) await dialog.waitFor({ timeout: 10_000 }) const spec = target === 'package' ? 'ime-confirm-package' : 'ime-confirm-registry' const specField = dialog.getByRole('textbox', { name: '包名或地址', exact: true }) await specField.fill(spec) let field = specField const customRegistry = 'https://registry.example.test/' if (target === 'custom registry') { const registryToggle = dialog.getByRole('button', { name: /^安装源/ }) await registryToggle.click() const registry = probe.locator('[data-install-registry]') const offered = registry.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }) const custom = registry.getByRole('radio', { name: '自定义地址', exact: true }) field = registry.getByRole('textbox', { name: '自定义地址', exact: true }) expect(await offered.isChecked()).toBe(true) expect(await custom.isChecked()).toBe(false) await offered.focus() await probe.keyboard.press('Tab') expect(await field.evaluate(element => element === document.activeElement)).toBe(true) expect(await offered.isChecked()).toBe(true) expect(await custom.isChecked()).toBe(false) await probe.keyboard.press('Shift+Tab') expect(await offered.evaluate(element => element === document.activeElement)).toBe(true) await probe.keyboard.press('Shift+Tab') await registry.waitFor({ state: 'detached' }) expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true) await probe.keyboard.press('Enter') await custom.focus() await probe.keyboard.press('Space') expect(await custom.isChecked()).toBe(true) expect(await offered.isChecked()).toBe(false) expect(await field.evaluate(element => element === document.activeElement)).toBe(true) await field.fill(customRegistry) await probe.keyboard.press('Shift+Tab') expect(await custom.evaluate(element => element === document.activeElement)).toBe(true) await probe.keyboard.press('Tab') expect(await field.evaluate(element => element === document.activeElement)).toBe(true) await probe.keyboard.press('Tab') await registry.waitFor({ state: 'detached' }) expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true) await probe.keyboard.press('Enter') await custom.focus() await probe.keyboard.press('Tab') expect(await field.evaluate(element => element === document.activeElement)).toBe(true) expect(await custom.isChecked()).toBe(true) } const value = target === 'package' ? spec : customRegistry const assertUnsubmitted = async (label: string) => { expect(await field.inputValue()).toBe(value) expect(await field.isEditable()).toBe(true) expect(await dialog.getByRole('button', { name: '安装', exact: true }).isEnabled()).toBe(true) expect(inspectSpy).not.toHaveBeenCalled() expect(installSpy).not.toHaveBeenCalled() trace.push(`${target} / ${label}: inspect=0, install=0; editable; value retained`, await field.ariaSnapshot()) } await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) try { for (const event of [ { label: 'isComposing=true', isComposing: true, keyCode: 13 }, { label: 'Safari isComposing=false, keyCode=229', isComposing: false, keyCode: 229 }, ]) { await field.dispatchEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, isComposing: event.isComposing, keyCode: event.keyCode, }) await assertUnsubmitted(event.label) } const unmarkedEnter = { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, isComposing: false, keyCode: 13 } await field.dispatchEvent('compositionstart') await field.dispatchEvent('keydown', unmarkedEnter) await assertUnsubmitted('compositionstart + unmarked Enter') await field.dispatchEvent('compositionend') await field.dispatchEvent('keydown', unmarkedEnter) await assertUnsubmitted('compositionend + immediate unmarked Enter') await probe.clock.runFor(9) await field.dispatchEvent('keydown', unmarkedEnter) await assertUnsubmitted('compositionend + 9ms unmarked Enter') await probe.clock.runFor(2) } finally { await probe.clock.resume() } await field.press('Enter') await expect.poll(() => inspectSpy.mock.calls.length, { timeout: 10_000 }).toBe(1) await expect.poll(() => installSpy.mock.calls.length, { timeout: 10_000 }).toBe(1) expect(inspectSpy.mock.calls[0]?.[0]).toBe(spec) expect(installSpy.mock.calls[0]?.[0]).toBe(spec) expect(installSpy.mock.calls[0]?.[1]).toMatchObject({ enabled: false, registry: target === 'package' ? null : customRegistry }) const failed = probe.getByRole('dialog', { name: '插件安装失败', exact: true }) await failed.waitFor({ timeout: 10_000 }) trace.push(`${target} / plain Enter: inspect=1, install=1`, await failed.ariaSnapshot()) await failed.getByRole('button', { name: '关闭', exact: true }).click() await failed.waitFor({ state: 'hidden', timeout: 10_000 }) } expect(await homeFile('profiles', 'scaffold', 'package.json')).toBe(manifest) expect(consoleWatch.pageErrors).toEqual([]) trace.push('Profile manifest unchanged; controlled Host result performed no package installation.') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ime-enter.expected.md'), trace.join('\n'), MODE) } finally { try { await context.close() } finally { registrySpy.mockRestore() inspectSpy.mockRestore() installSpy.mockRestore() } } }, 60_000) it('checks a spec before installing it and words what the check refused', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install')) const panel = await openPluginsPanel() await panel.getByRole('button', { name: '添加插件', exact: true }).click() const dialog = page.getByRole('dialog', { name: '添加插件' }) await dialog.waitFor({ timeout: 10_000 }) const field = dialog.getByRole('textbox', { name: '包名或地址' }) expect(await page.getByRole('menu').count()).toBe(0) await expect.poll(() => field.evaluate(element => document.activeElement === element)).toBe(true) await dialog.getByRole('button', { name: '关闭', exact: true }).click() await dialog.waitFor({ state: 'hidden' }) await panel.getByRole('button', { name: '选择添加插件方式', exact: true }).click() const menu = page.getByRole('menu') await menu.getByRole('menuitem', { name: /^安装第三方插件/ }).click() await menu.waitFor({ state: 'hidden' }) await dialog.waitFor({ state: 'visible' }) await expect.poll(() => field.evaluate(element => document.activeElement === element)).toBe(true) const install = dialog.getByRole('button', { name: '安装', exact: true }) expect(await install.isDisabled()).toBe(true) expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信') expect(await dialog.getByRole('note').textContent()).toContain('按需安装的官方插件会在版本与 DSH 不同时提供“更新”') expect(await dialog.getByRole('note').textContent()).toContain('其他插件请先卸载再安装新版') await dialog.getByRole('button', { name: '插件安装引导和示例' }).click() // The guide carries the package-name example only; the former template strings keep their replacement reminder. await expect.poll(() => dialog.getByRole('listitem').count()).toBe(1) await dialog.getByRole('button', { name: '填入示例 dsh-plugin-whale-pet' }).click() expect(await field.inputValue()).toBe('dsh-plugin-whale-pet') expect(await dialog.getByRole('status').count()).toBe(0) for (const [example, hint] of [ ['https://github.com/author/dsh-plugin', '请替换为实际的 Git 仓库地址'], ['/Users/name/my-plugin', '请替换为本机插件目录的实际路径'], ] as const) { await field.fill(example) expect(await dialog.getByRole('status').textContent()).toBe(hint) } await field.fill('/actual/plugin-directory') expect(await dialog.getByRole('status').count()).toBe(0) await dialog.getByRole('button', { name: '收起引导' }).click() expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信') // A name the list already shows is refused without asking the Host. await field.fill('@fixture/bundle') await install.click() await dialog.getByRole('alert').waitFor({ timeout: 5_000 }) expect(await dialog.getByRole('alert').textContent()).toBe('该插件已安装。如需升级,请卸载后重新安装') // A path the Host cannot read as a package is refused with its reason, and the spec stays editable. await field.fill(join(scaffold.harnessHome, 'no-such-plugin')) await install.click() await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toBe('该路径不存在或不是有效的插件包') expect(await field.isDisabled()).toBe(false) // A name the registry would refuse never reaches it. await field.fill('Not A Package') await install.click() await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toContain('无法识别这个包名或地址') await dialog.getByRole('button', { name: '关闭' }).click() await expect.poll(() => page.getByRole('dialog', { name: '添加插件' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable')) const panel = await openPluginsPanel() const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) await toggle.waitFor({ timeout: 20_000 }) const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row') expect(mounted()?.fiber?.state).toBeUndefined() await toggle.click() const bundles = async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) // A live profile: the row mounts once the whole tree recomposed, the switch is on, and nothing waits for a restart. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') expect(await panel.getByText(/下次启动生效/).count()).toBe(0) const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE) // The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }) await rowSwitch.waitFor({ timeout: 10_000 }) expect(await rowSwitch.getAttribute('aria-checked')).toBe('true') await rowSwitch.click() await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'cordis.patch.yml')).includes('fixture-row'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true) await expect.poll(() => rowSwitch.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') // A disabled entry keeps its disposed fiber; only an active one counts as mounted. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) await rowSwitch.click() await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2) await panel.getByRole('button', { name: '返回插件' }).click() await toggle.click() await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(tripwire.pageErrors).toEqual([]) }, 90_000) it('reveals LiveShare only while its plugin rows run, and hides it again when they stop', async () => { // A dedicated scaffold keeps this profile patch away from the shared fixture profile above. const visibilityScaffold = await launchWebScaffold({ profile: { packages: [] } }) const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) try { const probe = await context.newPage() onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-liveshare-visibility')) const consoleWatch = watchConsole(probe) await probe.goto(visibilityScaffold.authenticatedUrl, { waitUntil: 'load' }) await probe.getByRole('button', { name: '插件', exact: true }).click() const panel = probe.locator('[data-plugin-panel]') await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 20_000 }) const liveShare = '@deepseek-ai/dsh-experimental-liveshare' const card = panel.locator(`[data-plugin-package="${liveShare}"]`) // The profile is a managed one (the plugin manager lists its bundles) whose base bundle keeps HMR // on, so writing the patch file recomposes the running loader; each step first waits for that // recomposition and then re-reads the Host bundle list through the page's own Refresh. const patchFile = join(visibilityScaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') const declarationsRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()] .some(entry => entry.options.id === 'liveshare-viewer-declarations') const refresh = panel.getByRole('button', { name: '刷新', exact: true }) // Default install: the package ships, but no row it declares runs, so neither list offers it. await expect.poll(() => card.count(), { timeout: 20_000, message: 'the default profile lists a LiveShare card before its rows run', }).toBe(0) await panel.getByRole('button', { name: '更多', exact: true }).click() await probe.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() await expect.poll(() => card.count(), { timeout: 20_000, message: 'the default More list offers a LiveShare card before its rows run', }).toBe(0) await panel.getByRole('button', { name: '返回插件', exact: true }).click() // Enabling through cordis.patch.yml runs one of the bundle's declared rows while the profile still // selects no such bundle: the card joins the experimental list. await writeFile(patchFile, [ '- insert:', ' - id: liveshare-viewer-declarations', " name: '@deepseek-ai/dsh-experimental-liveshare/viewer-declarations'", '', ].join('\n')) await expect.poll(declarationsRow, { timeout: 30_000, message: 'the profile patch did not compose the LiveShare declarations row', }).toBe(true) await refresh.click() await expect.poll(() => card.count(), { timeout: 30_000, message: 'the patch-enabled bundle did not join the experimental list', }).toBe(1) // Removing the row stops it: the card leaves both lists again. await writeFile(patchFile, '[]\n') await expect.poll(declarationsRow, { timeout: 30_000, message: 'the profile patch did not remove the LiveShare declarations row', }).toBe(false) await refresh.click() await expect.poll(() => card.count(), { timeout: 30_000, message: 'the card stayed listed after its rows stopped', }).toBe(0) await panel.getByRole('button', { name: '更多', exact: true }).click() await probe.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() await expect.poll(() => card.count(), { timeout: 20_000, message: 'the More list kept the card after its rows stopped', }).toBe(0) expect(consoleWatch.pageErrors).toEqual([]) } finally { await context.close() await visibilityScaffold.close() } }, 120_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', 'ime-enter.expected.md', 'header-responsive.expected.md', 'header-natural-motion.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md', ]) }) }) describe('web e2e: startup-applied plugin management', () => { it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => { const scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] }, }) let browser: Browser | undefined try { browser = await chromium.launch() const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const tripwire = watchConsole(page) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-live')) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() const panel = page.locator('[data-plugin-panel]') const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) await toggle.waitFor({ timeout: 20_000 }) const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row') const bundles = async () => { const text = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8') return (JSON.parse(text) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles } expect(mounted()?.fiber?.state).toBeUndefined() await toggle.click() // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so. await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') await page.getByText('请重启 DSH 使更改生效。这个 profile 只在启动时应用更改。', { exact: true }).waitFor({ timeout: 10_000 }) expect(mounted()?.fiber?.state).toBeUndefined() // The pack's page lists its rows from their declarations, with no live entry to switch. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true) await panel.getByRole('button', { name: '返回插件' }).click() await toggle.click() await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual(SCAFFOLD_BUNDLES) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(tripwire.pageErrors).toEqual([]) } finally { await browser?.close() await scaffold.close() } }, 60_000) })