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.
295 lines
13 KiB
TypeScript
295 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 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)
|
||
})
|