DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features.
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
/**
|
||
* Perf lane: a MEASUREMENT harness, not a pass/fail gate (beyond the mount
|
||
* sanity it shares with the other lanes).
|
||
*
|
||
* It exists to put NUMBERS on the sidebar's client-side cost in a real
|
||
* mounted `dsh web` (the same scratch-profile boot as scripts/e2e-mount.sh):
|
||
*
|
||
* 1. mount latency — navigationStart → `[data-dsh-better-sidebar]`
|
||
* attached, sampled by an init-script probe (rAF loop, so it catches
|
||
* the attach even before Playwright's first poll);
|
||
* 2. longtasks — every >50ms main-thread task from first paint
|
||
* through the full built-in tab sweep (the heaviest mount surface);
|
||
* 3. bundle cost — per-resource transferSize/decodedBodySize/duration
|
||
* for everything sidebar-shaped the page fetched;
|
||
* 4. bottom-drag frames — rAF frame-interval p95/max while dragging the
|
||
* bottom strip (the layout-push hot path), plus the width-leak guard
|
||
* from the drag lane (this shell pushes no width at all: the right
|
||
* column belongs to DSH's native Sidebar).
|
||
*
|
||
* Results print as single-line `PERF_JSON` records (plus a `PERF_SUMMARY`
|
||
* aggregate) so a shell run can `tee` the log and grep them out; the
|
||
* before/after comparison lives in docs/plans/2026-08-31-perf-optimization.md.
|
||
* Run it 3× and take the median of each metric — single-run numbers on a
|
||
* developer machine are noise, not baselines.
|
||
*/
|
||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
|
||
|
||
/** This lane's own workspace (lanes run serially against one server — never
|
||
* share seed paths with mount/drag). */
|
||
const WORKSPACE_PATH = process.env.DSH_E2E_PERF_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-perf-workspace')
|
||
|
||
/**
|
||
* Every tab type the composed guide must offer: the plugin's own four plus
|
||
* DSH 0.1.6's host-owned terminal and browser. This plugin deliberately ships
|
||
* neither of those, so exactly one entry each proves it is not shadowing the
|
||
* host. The ids are the guides' `data-sidebar-right-guide-entry` keys.
|
||
*/
|
||
const NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal', 'browser'] as const
|
||
|
||
let api: APIRequestContext
|
||
|
||
async function seedSession(): Promise<void> {
|
||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'perf lane\n')
|
||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
|
||
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||
}
|
||
|
||
test.beforeAll(async () => {
|
||
api = await createHostApi()
|
||
await seedSession()
|
||
})
|
||
|
||
test.afterAll(async () => {
|
||
await api?.dispose()
|
||
})
|
||
|
||
/** The longtask collector + mount-attach probe, injected before ANY page
|
||
* script runs (longtasks observed late are gone forever). */
|
||
const PERF_PROBE = `(() => {
|
||
const perf = { mountAt: null, longtasks: [] }
|
||
window.__perfProbe = perf
|
||
new PerformanceObserver(list => {
|
||
for (const entry of list.getEntries()) perf.longtasks.push({ start: entry.startTime, duration: entry.duration })
|
||
}).observe({ entryTypes: ['longtask'] })
|
||
const check = () => {
|
||
if (document.querySelector('[data-dsh-better-sidebar]') !== null) { perf.mountAt = performance.now(); return }
|
||
requestAnimationFrame(check)
|
||
}
|
||
requestAnimationFrame(check)
|
||
})()`
|
||
|
||
function percentile(values: number[], p: number): number {
|
||
if (values.length === 0) return 0
|
||
const sorted = [...values].sort((a, b) => a - b)
|
||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
|
||
return sorted[index]!
|
||
}
|
||
|
||
interface ResourceStat {
|
||
name: string
|
||
transferSize: number
|
||
decodedBodySize: number
|
||
duration: number
|
||
}
|
||
|
||
/** Dismiss whatever onboarding takeover is present (same dance as the other
|
||
* lanes) so the pointer can reach the sidebar's controls. */
|
||
async function dismissOnboarding(page: Page): Promise<void> {
|
||
try {
|
||
await expect
|
||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||
.toBeGreaterThan(0)
|
||
} catch {
|
||
console.warn('[e2e-perf] no onboarding takeover appeared; proceeding')
|
||
}
|
||
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 a takeover stacked above; retry in the next round.
|
||
}
|
||
}
|
||
if (!dismissed) break
|
||
}
|
||
}
|
||
|
||
test('measure: mount latency, longtasks and bundle cost through a full tab sweep', async ({ page }) => {
|
||
await page.addInitScript(PERF_PROBE)
|
||
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 })
|
||
|
||
const mountLatency = await page.evaluate(() =>
|
||
(window as unknown as { __perfProbe: { mountAt: number | null } }).__perfProbe.mountAt)
|
||
expect(mountLatency, 'the mount probe must have caught the attach').not.toBeNull()
|
||
|
||
await dismissOnboarding(page)
|
||
|
||
// The native Sidebar's way in lives in the conversation header's corner,
|
||
// which DSH renders only for a session with content.
|
||
await sendFirstMessage(page)
|
||
|
||
// Open DSH's native right Sidebar and sweep every plugin tab type through
|
||
// its guide page — the same crash-sweep surface as mount.e2e.ts, reused
|
||
// here as the workload (the plugin's content lives in the native surface on
|
||
// 0.1.5; the plugin's own panel keeps only its bottom workbench).
|
||
await page.locator('[data-sidebar-right-expand]').first().click()
|
||
const pane = page.locator('[data-sidebar-right-panel]')
|
||
await expect(pane).toBeVisible({ timeout: 90_000 })
|
||
const addTab = page.locator('[data-dockkit-add-tab]').first()
|
||
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, `native tab type "${kind}" must be offered by the guide`).toHaveCount(1)
|
||
await entry.click()
|
||
await page.waitForTimeout(1_500)
|
||
}
|
||
|
||
// Let the last tab settle (lazy chunks + first render) before reading.
|
||
await page.waitForTimeout(2_000)
|
||
const probe = await page.evaluate(() => (window as unknown as { __perfProbe: { mountAt: number | null; longtasks: Array<{ start: number; duration: number }> } }).__perfProbe)
|
||
const resources = await page.evaluate(() =>
|
||
performance.getEntriesByType('resource')
|
||
.filter(entry => /sidebar/.test(entry.name))
|
||
.map(entry => {
|
||
const resource = entry as PerformanceResourceTiming
|
||
return {
|
||
name: entry.name.replace(/^https?:\/\/[^/]+/, ''),
|
||
transferSize: resource.transferSize,
|
||
decodedBodySize: resource.decodedBodySize,
|
||
duration: entry.duration,
|
||
}
|
||
}) as ResourceStat[],
|
||
)
|
||
|
||
const longtasks = probe.longtasks ?? []
|
||
const record = {
|
||
metric: 'mount-sweep',
|
||
mountLatencyMs: mountLatency,
|
||
longtaskCount: longtasks.length,
|
||
longtaskTotalMs: Math.round(longtasks.reduce((sum, task) => sum + task.duration, 0)),
|
||
longtaskMaxMs: longtasks.reduce((max, task) => Math.max(max, task.duration), 0),
|
||
resourceCount: resources.length,
|
||
resourceTransferTotal: resources.reduce((sum, r) => sum + r.transferSize, 0),
|
||
resourceDecodedTotal: resources.reduce((sum, r) => sum + r.decodedBodySize, 0),
|
||
resources,
|
||
}
|
||
console.log(`PERF_JSON ${JSON.stringify(record)}`)
|
||
console.log(
|
||
`PERF_SUMMARY mount=${Math.round(mountLatency!)}ms longtasks=${record.longtaskCount}/${record.longtaskTotalMs}ms `
|
||
+ `resources=${record.resourceCount}/${(record.resourceTransferTotal / 1024).toFixed(0)}KiB`,
|
||
)
|
||
})
|
||
|
||
test('measure: bottom-strip drag frame pacing', async ({ page }) => {
|
||
await page.addInitScript(PERF_PROBE)
|
||
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 })
|
||
await dismissOnboarding(page)
|
||
|
||
// The workbench's expand/collapse control is registered into DSH's
|
||
// session-header utilities, which DSH renders only for a session with
|
||
// content — seed one message first, then address the toggle by its stable
|
||
// data attribute (it is NOT inside the plugin's own host).
|
||
await sendFirstMessage(page)
|
||
|
||
// The plugin pushes no width ever (the right column is DSH's native
|
||
// Sidebar), so the drag must never write --dsh-sidebar-width — the guard
|
||
// the drag lane locks, recorded here for the perf story.
|
||
const bottomExpand = page.locator('[data-dsh-bottom-toggle]')
|
||
await expect(bottomExpand).toHaveCount(1)
|
||
await bottomExpand.click()
|
||
await expect
|
||
.poll(async () => {
|
||
const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
|
||
return value
|
||
}, { timeout: 90_000 })
|
||
.toBeGreaterThan(0)
|
||
|
||
// Frame-pacing sampler: rAF timestamps during the drag only.
|
||
await page.evaluate(() => {
|
||
const frames: number[] = []
|
||
const pushWidths: number[] = []
|
||
const loop = (): void => {
|
||
frames.push(performance.now())
|
||
const width = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||
pushWidths.push(Number.isNaN(width) ? 0 : width)
|
||
requestAnimationFrame(loop)
|
||
}
|
||
requestAnimationFrame(loop)
|
||
;(window as unknown as { __dragFrames: number[]; __dragPushWidths: number[] }).__dragFrames = frames
|
||
;(window as unknown as { __dragPushWidths: number[] }).__dragPushWidths = pushWidths
|
||
})
|
||
|
||
// The panel slides down into place on expand: read the strip only after it
|
||
// settles at innerHeight - the pushed height (a mid-animation box makes the
|
||
// pointerdown land off-strip and the drag never starts).
|
||
await expect
|
||
.poll(async () => {
|
||
const probe = await page.evaluate(() => {
|
||
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||
if (bottom === null) return null
|
||
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
|
||
if (strip === undefined) return null
|
||
const r = strip.getBoundingClientRect()
|
||
const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0
|
||
return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar))
|
||
})
|
||
return probe === null ? Number.NaN : probe
|
||
}, { timeout: 30_000 })
|
||
.toBeLessThanOrEqual(8)
|
||
const stripPoint = await page.evaluate(() => {
|
||
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||
if (bottom === null) return null
|
||
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
|
||
if (strip === undefined) return null
|
||
const r = strip.getBoundingClientRect()
|
||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
|
||
})
|
||
expect(stripPoint, 'the bottom drag strip must be present').not.toBeNull()
|
||
|
||
const t0 = await page.evaluate(() => performance.now())
|
||
await page.mouse.move(stripPoint!.x, stripPoint!.y)
|
||
await page.mouse.down()
|
||
for (let i = 1; i <= 12; i++) {
|
||
await page.mouse.move(stripPoint!.x, stripPoint!.y - i * 8, { steps: 2 })
|
||
await page.waitForTimeout(40)
|
||
}
|
||
await page.mouse.up()
|
||
const t1 = await page.evaluate(() => performance.now())
|
||
await page.waitForTimeout(300)
|
||
|
||
const { frames, widths } = await page.evaluate(() => ({
|
||
frames: (window as unknown as { __dragFrames: number[] }).__dragFrames,
|
||
widths: (window as unknown as { __dragPushWidths: number[] }).__dragPushWidths,
|
||
}))
|
||
const dragFrames = frames.filter(t => t >= t0 && t <= t1)
|
||
const intervals: number[] = []
|
||
for (let i = 1; i < dragFrames.length; i++) intervals.push(dragFrames[i]! - dragFrames[i - 1]!)
|
||
const dragWidths = widths.slice(Math.max(0, frames.indexOf(dragFrames[0] ?? frames[0]!)))
|
||
const record = {
|
||
metric: 'bottom-drag',
|
||
frames: dragFrames.length,
|
||
intervalMedianMs: Math.round(percentile(intervals, 50)),
|
||
intervalP95Ms: Math.round(percentile(intervals, 95)),
|
||
intervalMaxMs: Math.round(Math.max(0, ...intervals)),
|
||
pushWidthLeakMax: Math.max(0, ...dragWidths),
|
||
}
|
||
console.log(`PERF_JSON ${JSON.stringify(record)}`)
|
||
console.log(
|
||
`PERF_SUMMARY drag-frames=${record.frames} p50=${record.intervalMedianMs}ms p95=${record.intervalP95Ms}ms `
|
||
+ `max=${record.intervalMaxMs}ms widthLeak=${record.pushWidthLeakMax}px`,
|
||
)
|
||
expect(record.pushWidthLeakMax, 'the plugin must never push a width during the bottom drag').toBe(0)
|
||
})
|