/** * Headless-render mount lane: prove the npm-packed plugin mounts into a real * `dsh web` instance and renders without crashing the shell. * * The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web` * (with the plugin mounted through the official `dsh plugin add` channel) and * injects the base URL via `DSH_E2E_URL`. This spec: * * 1. seeds one workspace + one session through the host's own RPC surface * (the same `workspace.create` / `session.create` calls the UI makes), * so the sidebar has a real session to render; * 2. loads the page in headless Chromium and asserts the shell and the * plugin's `[data-dsh-better-sidebar]` host mount; * 3. asserts the plugin's crash markers never appear (no RenderBoundary / * fail() strips, no `pageerror`, no plugin-prefixed console errors); * 4. expands DSH's native right Sidebar, sweeps every built-in tab type * through its guide page (Files / Changes / Tasks / Terminal / Browser) — * including the lazily-fetched terminal chunk — and then opens seeded * files through the Files window's tree (separate mode: each file opens * its own new tab, the seeded home "Files" tab stays the explorer), * while response waits armed before goto prove the lazily-fetched editor * chunk (client-editor.js) and the mermaid chunk (client-mermaid.js, * rendered SVG diagram + zoom modal) loaded. * * Deterministic by construction: every wait is on a DOM/network marker, the * suite is serial (one server instance), and any crash trips the very next * assertion. */ import { mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, expect, type APIRequestContext } from '@playwright/test' import { PAGE_URL, createHostApi, gotoPage, hostRpc, sendFirstMessage, sidebarApi } from './host' /** Workspace the sidebar renders against (created by the lane's seeding). */ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace') /** A file seeded into the workspace, opened through the Files window's tree to * exercise the file-open path (editor chunk = client-editor.js). */ const SEEDED_FILE = 'hello.txt' /** A markdown file with a mermaid fence, opened through the Files window's * tree to force the lazily-packed mermaid chunk (client-mermaid.js) to load * and render a sanitized SVG diagram. */ const SEEDED_MD_FILE = 'diagram.md' /** A GitHub-style README markdown (badge div,
nesting markdown, * inline tags in table cells), opened through the Files window's tree to * prove raw-HTML runs render as sanitized DOM and the TOC outline works. */ const SEEDED_README_FILE = 'readme-style.md' /** * The plugin's crash markers. The client mounts inside an error boundary that * renders a strip whose text starts with these prefixes instead of crashing * (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx). */ const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/] /** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */ const NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal', 'browser'] let api: APIRequestContext /** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */ let seededSessionId: string /** Seed one workspace + one session (plus files for the editor/mermaid-chunk * probes) through the host's unary RPC surface. */ async function seedSession(): Promise { mkdirSync(WORKSPACE_PATH, { recursive: true }) writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n') // The mermaid-chunk probe file: a markdown doc whose preview must fetch // client-mermaid.js and render the fence into an SVG diagram. The // reference-style link's definition sits AFTER the fence: it only // resolves when the preview is one single markdown parse (the mermaid // path must not split the document into independent MarkdownText blocks). writeFileSync(join(WORKSPACE_PATH, SEEDED_MD_FILE), [ '# Diagram', '', '[before][shared]', '', '```mermaid', 'graph TD', ' A[Hello] --> B[World]', '```', '', '[shared]: https://example.com', '', 'tail text', '', ].join('\n')) // The README-style probe file: raw-HTML runs (badge wall div with an // embedded script, a
nesting a fence + heading) plus inline tags // inside a table cell. The script must be sanitized away in the preview. writeFileSync(join(WORKSPACE_PATH, SEEDED_README_FILE), [ '# Readme Style', '', '
', ' badge', ' ', '
', '', '## Setup', '', '[docs link][def]', '', '
', 'Steps', '', '### Inside', '', '```sh', 'dsh plugin --profile web add x', '```', '', '
', '', '## Table', '', '| col | note |', '| --- | --- |', '| alpha | line one
line two |', '', '[def]: https://example.com/def', '', ].join('\n')) // Seeded through the lanes' dual-protocol RPC helper (./host): 0.1.1-rc.x // dot endpoints first, 0.1.2-alpha.1+ slash endpoints on 404 fallback, and // the request context carries the auth cookie when the launch URL had a // one-time token. const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) const session = await hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) seededSessionId = session.value.sessionId } test.beforeAll(async () => { api = await createHostApi() await seedSession() }) test.afterAll(async () => { await api?.dispose() }) test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => { const pageErrors: string[] = [] const consoleErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) // Load the shell. The app renders into #root; the plugin appends its own // [data-dsh-better-sidebar] host once its client half activates. // // The editor chunk (client-editor.js) loads as soon as ANY files-window tab // renders — the seeded home tab mounts the moment the panel expands, long // before the tree click below — so the response wait must be armed BEFORE // goto, or it misses the fetch and times out. const editorChunk = page.waitForResponse( (response) => response.url().includes('/sidebar/bundle/editor.js'), { timeout: 120_000 }, ) await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) // The unified panel host: the fixed containing block every panel lives in // (data-dsh-panel-host). Its presence is part of the injection contract. await expect(page.locator('[data-dsh-panel-host]')).toBeAttached({ timeout: 90_000 }) // The host's global z-index is part of the layering contract: it must // sit above the AppFrame overlay layer (20) and below DSH's ui-cordis // dynamic-plugin panel (fixed, 30) so that surface is never hidden behind // the workbench, and below the DSH float stack (100+). const hostZ = await page.locator('[data-dsh-panel-host]').evaluate( (el) => Number.parseFloat(getComputedStyle(el).zIndex), ) expect(hostZ).toBeGreaterThan(20) expect(hostZ).toBeLessThan(30) // A keyless boot stacks onboarding takeovers that mask the whole shell: a // versioned welcome notice ("Continue", persists its acknowledgement to // settings) and, once that is acknowledged, a provider-config dialog // ("Configure later", session-only — always present while no credential is // configured). Both mount only after the settings join resolves. Wait // (bounded) for one to appear; a DSH build without onboarding proceeds // straight to the sweep. try { await expect .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) .toBeGreaterThan(0) } catch { console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal') } // Dismiss whatever takeover is present, in any stacking order, until none // remain — a masked click is retried next round instead of failing. for (let round = 0; round < 8; round++) { let dismissed = false for (const name of ['Continue', 'Configure later']) { const button = page.getByRole('button', { name, exact: true }).first() if ((await button.count()) === 0) continue try { await button.click({ timeout: 4_000 }) dismissed = true await page.waitForTimeout(1_000) } catch { // Masked by the takeover stacked above it; the next round tries the // other button first. } } if (!dismissed) break } // The seeded session must give the sidebar a session scope: without it the // workbench has no pane to render and the tab sweep is impossible. const tabBar = sidebar.locator('[title]') await expect(tabBar.first()).toBeAttached({ timeout: 90_000 }) // Regression: with the panels still COLLAPSED, the document must not grow // beyond the viewport. Collapsed panels are slid off-screen with // `transform: translate(102%)`, and a transformed element still // contributes to its ancestors' scrollable overflow — unclipped, the // hidden panels extended the document's scroll area and the whole page // became draggable left-right and up-down. The panel host clips at the // viewport edge (overflow: hidden), so scrollWidth/scrollHeight must stay // within the viewport here. (Assert <= not == : a classic scrollbar // narrows clientWidth, so scrollWidth may sit slightly under innerWidth.) await expect .poll( () => page.evaluate(() => ({ overflowX: document.documentElement.scrollWidth <= window.innerWidth, overflowY: document.documentElement.scrollHeight <= window.innerHeight, })), { timeout: 30_000 }, ) .toEqual({ overflowX: true, overflowY: true }) // Crash-marker assertions shared by every step. const assertNoCrash = async (): Promise => { await expect .poll(async () => pageErrors, { timeout: 5_000 }) .toEqual([]) // Fail with the actual strip text so a regression is diagnosable from // the test report alone (a strip renders the client fail() message). const stripTexts = await sidebar.locator('div').evaluateAll( (nodes, patterns) => nodes.filter((node) => { const text = (node.textContent ?? '').trim() return patterns.some((pattern) => pattern.test(text)) }).map((node) => (node.textContent ?? '').trim()), CRASH_STRIP_PATTERNS, ) expect(stripTexts, 'a dsh-better-sidebar error strip is present in the sidebar').toEqual([]) } // The native Sidebar's way in lives in the conversation header's corner, // which DSH renders only for a session with content — the seeded session // starts blank, so give it one message first. await sendFirstMessage(page) // The plugin's own surface is the bottom workbench: its expand/collapse // control is registered into DSH's session-header utilities (the header's // corner belongs to the native sidebar), and the workbench host itself is // mounted. Both are stable addressing surfaces for user CSS / presets. await expect(page.locator('[data-dsh-bottom-toggle]')).toBeAttached({ timeout: 30_000 }) await expect(page.locator('[data-dsh-bottom-panel]')).toBeAttached() // DSH 0.1.5 owns the right column: the plugin contributes tab TYPES to the // host's native right Sidebar instead of drawing its own panel. Open it // through the host's own control (the conversation header's corner) and // drive the plugin's content from the guide page the surface seeds. const pane = page.locator('[data-sidebar-right-panel]') const addTab = page.locator('[data-dockkit-add-tab]').first() await page.locator('[data-sidebar-right-expand]').first().click() await expect(pane).toBeVisible({ timeout: 30_000 }) await expect( page.locator('[data-sidebar-right-guide]'), 'a freshly expanded surface shows the guide page', ).toBeVisible({ timeout: 30_000 }) // Every built-in descriptor must be registered as a native tab type: the // guide lists one entry per type, so a missing entry is a real regression // (descriptor removed, registration failed, or the type got disabled). for (const kind of NATIVE_TABS) { await expect( page.locator(`[data-sidebar-right-guide-entry="${kind}"]`), `the plugin's native tab type "${kind}" is not offered by the guide`, ).toHaveCount(1) } // Sweep every type through the guide. Each open mounts a real viewer (the // terminal fetches its lazy chunk); a failure anywhere surfaces as a // pageerror or a crash strip, both of which the next assertion sees. A pane // holds one guide tab, so re-seed it through the strip's add control before // every pick. for (const kind of NATIVE_TABS) { if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click() const entry = page.locator(`[data-sidebar-right-guide-entry="${kind}"]`) await expect(entry, `guide entry "${kind}" must be reachable`).toHaveCount(1, { timeout: 30_000 }) await entry.click() await page.waitForTimeout(1_500) await assertNoCrash() } // The sweep opened the Side Chat type, whose view auto-creates a thread and // polls the transcript — that poll MUST ride the plugin's own // sidechat.events route (the host transport this lane locks). The poll runs // only while the tab is visible, so activate its chip first. await pane.getByRole('tab', { name: /Side Chat|侧边对话|侧边聊天/ }).first().click() await expect .poll( () => page.evaluate(() => performance.getEntriesByType('resource').some(entry => entry.name.includes('/sidebar/api/sidechat.events'))), { timeout: 30_000 }, ) .toBe(true) // The native tab body host is a BLOCK scroller with a definite height, not a // flex container, so a tab root that only declares `flex: 1` collapses to its // content height — exactly how the side-chat composer used to drift away from // the pane bottom. The native adapter wraps every body in a full-height column // host; assert the box really fills its pane and the composer sits on the // pane's floor. This is the regression guard for that fill contract. await expect .poll( () => page.evaluate(() => { const hosts = [...document.querySelectorAll('[data-dsh-native-tab-host]')] const host = hosts.find(element => element.getBoundingClientRect().height > 0) if (host === undefined) return 'no visible native tab host' // The wrapper's parent is the SLOT HOST, which renders with // `display: contents` and therefore has no box of its own; the pane // body is the first ancestor that actually draws one. const view = host.ownerDocument.defaultView let paneBody = host.parentElement while (paneBody !== null && (view?.getComputedStyle(paneBody).display ?? '') === 'contents') { paneBody = paneBody.parentElement } if (paneBody === null) return 'the native tab host has no boxed ancestor' const fillGap = Math.round(paneBody.getBoundingClientRect().height - host.getBoundingClientRect().height) if (Math.abs(fillGap) > 2) return `tab body does not fill its pane: gap ${fillGap}px` const composer = document.querySelector('[class*="sidechatComposer"]') if (composer === null) return 'the side-chat composer is not rendered' // The composer's own 8px bottom margin is the only allowed gap. const bottomGap = Math.round(paneBody.getBoundingClientRect().bottom - composer.getBoundingClientRect().bottom) return bottomGap <= 12 ? 'filled' : `the composer sits ${bottomGap}px above the pane bottom` }), { timeout: 30_000 }, ) .toBe('filled') // DSH 0.1.5-rc.1+ renders a guide entry's `description` line only while // the guide lists at most 4 entries (`MAX_DESCRIBED_ENTRIES` in the host's // GuideBody) — a longer list drops every description and shows titles // alone. Shrink the enabled set through the plugin's OWN settings route // (scratch-profile prefs only, never the user's) so three types stay // enabled (files / sidechat / browser), then require the Files capsule to // really grow its description line. This is the host-side proof of the // rc.1 description restore: before it every entry was a title-only // capsule, so no entry count could ever surface the text. const settingsGet = await api.post(sidebarApi('settings.get'), { data: {} }) expect(settingsGet.ok(), `settings.get: ${settingsGet.status()}`).toBe(true) const settingsGetBody = (await settingsGet.json()) as { value?: { tabsEnabled?: Record } } const originalTabsEnabled = settingsGetBody.value?.tabsEnabled ?? {} /** The types this check switches off (leaving three entries, i.e. ≤4). */ const shrunken = ['git', 'subagent', 'terminal'] as const try { // Send the FULL map back (the route's patch is key-wise merged, so a // full map is correct whether the host merges or replaces). const tabsEnabled: Record = { ...originalTabsEnabled } for (const id of shrunken) tabsEnabled[id] = false const tabsUpdate = await api.post(sidebarApi('settings.update'), { data: { patch: { tabsEnabled } } }) expect(tabsUpdate.ok(), `settings.update (tabsEnabled): ${tabsUpdate.status()} ${await tabsUpdate.text()}`).toBe(true) // Re-seed the guide (a pane holds one guide tab) and wait for the // shrunken list: the three remaining entries render their descriptions. if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click() const filesCapsule = page.locator('[data-sidebar-right-guide-entry="files"]') await expect(filesCapsule, 'the Files entry must still be offered after the shrink').toHaveCount(1, { timeout: 30_000 }) await expect( filesCapsule, 'with ≤4 guide entries the Files capsule must render its description line', ).toContainText('workspace tree', { timeout: 30_000 }) for (const disabled of shrunken) { await expect( page.locator(`[data-sidebar-right-guide-entry="${disabled}"]`), `the disabled "${disabled}" type must leave the guide`, ).toHaveCount(0) } } finally { // Restore the profile's original prefs even on failure. The patch merges // key-wise, so sending the original map back is NOT enough — the three // `false` entries written above would survive. Every key this check // touched is restored explicitly (an absent key means enabled, so a key // the profile never set goes back to `true`). This lane's later tests // (perf.e2e.ts) sweep the guide and would otherwise find types missing. const restored: Record = { ...originalTabsEnabled } for (const id of shrunken) restored[id] = originalTabsEnabled[id] ?? true const restore = await api.post(sidebarApi('settings.update'), { data: { patch: { tabsEnabled: restored } } }) expect(restore.ok(), `settings.update (restore tabsEnabled): ${restore.status()} ${await restore.text()}`).toBe(true) } // Side Chat host-route smoke against the REAL host: create a thread child // under the seeded session (custom-seed creation through AgentRegistry), // deliver a follow-up, cancel, and release it. The turn itself cannot run // (keyless boot has no model route), but admission + creation + the wire // envelope must all succeed — this is the deepest functional proof the // mount lane can make without a provider, and it is what caught the 0.1.5 // `sessionPersistence.inspect` removal. const start = await api.post(sidebarApi('sidechat.start'), { data: { sessionId: seededSessionId, question: 'mount lane smoke' }, }) expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true) const startBody = (await start.json()) as { ok: boolean; value?: { childId?: string }; error?: { code?: string; message?: string } } expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true) const childId = startBody.value?.childId expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/) const list = await hostRpc<{ items: Array<{ sessionId: string }> }>(api, 'session.list', {}) expect( list.value.items.some(item => item.sessionId === childId), 'the thread child must appear in the host session list', ).toBe(true) const eventsLive = await api.post(sidebarApi('sidechat.events'), { data: { childId } }) expect(eventsLive.ok(), `sidechat.events (live): ${eventsLive.status()} ${await eventsLive.text()}`).toBe(true) const eventsLiveBody = (await eventsLive.json()) as { ok: boolean; value?: { events?: Array<{ type: string }>; live?: unknown[] } } expect(eventsLiveBody.ok, `sidechat.events envelope: ${JSON.stringify(eventsLiveBody)}`).toBe(true) expect(Array.isArray(eventsLiveBody.value?.events), 'sidechat.events must answer an events array').toBe(true) expect(Array.isArray(eventsLiveBody.value?.live), 'sidechat.events must answer a live-delta array').toBe(true) for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) { const response = await api.post(sidebarApi(method), { data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId }, }) expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true) } // After dispose the agent is gone: the same read must fall back to the // PERSISTED log (the cold path a re-opened tab polls). const eventsCold = await api.post(sidebarApi('sidechat.events'), { data: { childId } }) expect(eventsCold.ok(), `sidechat.events (cold): ${eventsCold.status()} ${await eventsCold.text()}`).toBe(true) // The editor chunk (client-editor.js) only loads when the plugin's files // window renders. The plugin took the built-in `files` kind over, so the // guide's Files entry IS the plugin's explorer: open it and click the seeded // file in its tree — the highest-risk surface (CodeMirror chunk + fs routes). if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click() await page.locator('[data-sidebar-right-guide-entry="files"]').click() const fileRow = pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`) await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the plugin's explorer`).toHaveCount(1, { timeout: 30_000 }) // Click near the row's LEFT edge: hovering reveals an @-reference button at // the row's right end, and a center click on a narrow dock lands on it // (referencing the file into the composer instead of opening it). await fileRow.click({ position: { x: 8, y: 8 } }) await editorChunk await expect( pane.locator('.cm-editor').first(), 'the plugin editor must render the seeded file inside the native tab', ).toBeVisible({ timeout: 30_000 }) await page.waitForTimeout(1_500) await assertNoCrash() // The mermaid chunk (client-mermaid.js) only loads when a previewed markdown // file contains a mermaid fence. Open the seeded diagram file from the // explorer and require the full round-trip: chunk fetch + sanitized SVG // diagram in the preview. const mermaidChunk = page.waitForResponse( (response) => response.url().includes('/sidebar/bundle/mermaid.js'), { timeout: 30_000 }, ) await pane.getByRole('tab', { name: /Files|文件/ }).first().click() const mdRow = pane.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`) await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the plugin's explorer`).toHaveCount(1, { timeout: 30_000 }) await mdRow.click({ position: { x: 8, y: 8 } }) await expect( pane.getByText('tail text'), 'the markdown preview must render the seeded document', ).toHaveCount(1, { timeout: 30_000 }) await mermaidChunk await expect( pane.locator('[data-mermaid-diagram] svg'), 'the mermaid fence must render into an SVG diagram in the markdown preview', ).toHaveCount(1, { timeout: 30_000 }) await expect( pane.locator('[data-mermaid-diagram]').first(), 'the diagram node labels must render inside the SVG', ).toContainText('Hello', { timeout: 30_000 }) // Cross-fence semantics: the reference-style link [before][shared] must // resolve to the definition that sits AFTER the fence — proof that the // preview is a single markdown parse and not per-fence fragments. await expect( pane.locator('a[href="https://example.com"]').first(), 'reference-style links with definitions across a mermaid fence must resolve', ).toContainText('before', { timeout: 30_000 }) // Click-to-enlarge: clicking the diagram opens the zoom modal (portalled to // document.body), Esc closes it again. const modal = page.locator('[data-mermaid-modal]') await pane.locator('[data-mermaid-diagram] svg').first().click() await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 }) await page.keyboard.press('Escape') await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 }) await assertNoCrash() // README-style markdown (raw-HTML runs + TOC): open the seeded file and // require the full round-trip — sanitized HTML leaves (badge image as a real // element, active content stripped), markdown nested inside the unclosed //
run, the inline pass turning the table cell's
into an // element, the reference link resolving across the HTML run, and the TOC // outline jumping into the collapsed details (auto-expanding it). await pane.getByRole('tab', { name: /Files|文件/ }).first().click() const readmeRow = pane.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`) await expect( readmeRow, `the seeded "${SEEDED_README_FILE}" file must appear in the plugin's explorer`, ).toHaveCount(1, { timeout: 30_000 }) await readmeRow.click({ position: { x: 8, y: 8 } }) await expect( pane.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'), 'the badge-wall div must render its image as a real element', ).toHaveCount(1, { timeout: 30_000 }) await expect( pane.locator('script'), 'the embedded