Files
apes-sidebar/tests/e2e/perf.e2e.ts
T
Menghuan1918 5912274e41 feat: yield the terminal and browser to DSH 0.1.6's own sidebar types
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.
2026-09-21 20:35:11 +08:00

290 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
})