Files
apes-sidebar/tests/e2e/perf.e2e.ts
T
figmar 5a7e2139a0 chore(sync): re-base the fork onto upstream dsh-better-sidebar v0.19.1
The fork was pinned at upstream v0.18.0. That is the entire reason the window
ran TWO sidebars and TWO near-identical panel toggles: DSH 2.0.10 ships its own
right sidebar (@deepseek-ai/dsh-client-ui-sidebar-right), and upstream only
adopted it in v0.19.0-alpha.1 — which arrived after our fork point. v0.19.1 is
also the one upstream line whose support range actually matches our host
(engines.dsh >=0.1.5-rc.1, 14 DSH peers ^0.1.5-rc.1, host = 0.1.5-rc.2), so it
is the only tag the host's startup precheck accepts.

What upstream v0.18.0 → v0.19.1 brings
- src/client/native/{index,surface,tab-adapter}.tsx: every tab the plugin can
  draw becomes an OFFICIAL native tab type + body, and an external plugin's
  descriptor registered later gets its native type too. This is what retires
  the fork's own right panel — and with it the duplicate toggle cluster
  (`toggleCluster` is gone from v0.19.1).
- native tab-chip icons via the `sidebar.right.pane.tab.title` seat, coloured
  built-in tab icons, file/folder glyphs from DSH's own `FileTypeIcon`.
- guide descriptions (DSH renders them while the guide lists <= 4 entries),
  tab bodies that fill the panel, and the 0.1.5-rc.2 adaptation work we were
  carrying by hand (peer/devDep pinning, primitives' transitive devDeps).

How the sync was done
Rebuilt the tree from the v0.19.1 tag rather than resolving 53 conflicted
files: our v0.18.0 delta was measured first and is only (a) one mechanical
rename `dsh-better-sidebar` -> `dsh-apes-sidebar` and (b) ten real edits. So:
tag tree + rename (scoped: code/config/scripts/tests/README, while the 46
historical docs/plans records and switch.sh's upstream package name stay
untouched) + re-applied deltas + our apes module.

Fork deltas re-applied on the new base
- src/client/apes/ (icons / locales / tabs): the three apes:* information-layer
  tabs (agents / apps / profile), registered through the same BetterSidebarService
  as the builtins — which is exactly why they now ride upstream's native adapter
  into the official sidebar for free.
- identity: package.json (name/version 1.2.0/description/forkedFrom), dsh.plugin.json
  (id/version), SIDEBAR_SERVICE_VERSION 1.2.0, README fork notice,
  docs/external-plugin-guide.md.
- scripts/switch.sh + the pnpm switch* scripts (apes <-> upstream two-line switch).
- tests/apes-tabs.spec.ts (unescaped: it starts from the v0.19.1 base).

Dropped
- the Phase A native-registration spike (`apes/native-tabs.tsx` + its spec): it
  re-derived by hand exactly what upstream's native layer already does — the
  hand-written bridge is dead weight now, and v0.19.1's own e2e/jsdom lanes
  cover the same seat contract far better.
- the unused apesProfileGuideDesc key and the two package.json.bak.* backups.

Verified on the new base: typecheck clean; build clean; 1351 passed / 9 skipped
(128 files), up from 1255 on the old base.
2026-10-03 22:51:09 +08:00

285 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-apes-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')
/** 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
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-apes-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-apes-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-apes-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-apes-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-apes-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)
})