Files
apes-sidebar/tests/e2e/perf.e2e.ts
T
Menghuan1918 6586e3e2d3 feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
2026-09-22 17:09:08 +08:00

295 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 the
* host's `terminal`. This plugin deliberately ships neither terminal nor
* browser, so exactly one `terminal` entry proves it is not shadowing the host.
*
* `browser` is deliberately absent: DSH 0.1.7 mounts
* `@deepseek-ai/dsh-client-ui-sidebar-browser` only for the desktop profile and
* this lane drives the web profile, so demanding a `browser` entry would demand
* something the host itself stopped offering. mount.e2e.ts asserts the absence
* explicitly. The ids are the guides' `data-sidebar-right-guide-entry` keys.
*/
const NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal'] 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)
})