Files
apes-sidebar/tests/e2e/toggle-layout.e2e.ts
T
figmar 2a7e030b3b chore: fork dsh-better-sidebar v0.18.0 as dsh-apes-sidebar v1.0.0
- Rename dsh-better-sidebar → dsh-apes-sidebar across 74 files (262 occurrences)
- Bump version 0.18.0 → 1.0.0 (package.json + dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
- Add forkedFrom metadata to package.json (upstream pin: commit 9e1a034)
- Add fork notice banner to README.md (MIT attribution preserved)
- Keep original MIT copyright (c) 2026 dsh-external
- Full gate green: typecheck + build (6 bundles) + test (1241 passed / 9 skipped)
2026-09-06 18:09:09 +08:00

185 lines
8.0 KiB
TypeScript

/**
* Toggle-layout lane (issue #315): while the right panel slides open/closed
* with its 300ms layout push, the BOTTOM panel must keep tracking the center
* column's horizontal edges frame by frame.
*
* The shell changed the bottom panel's geometry source from React state
* (setCenterRect per ResizeObserver frame → full Sidebar re-render at
* animation cadence) to a ref + direct inline writes, so the panel still
* follows the animated center column with zero React work. This lane samples
* the transition and asserts the bottom panel's edges stay glued to the
* center column (left/right within a rounding + one-frame tolerance) — the
* visual-equivalence proof for that change.
*
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
* page (own workspace, like drag-layout.e2e.ts).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
const WORKSPACE_PATH = process.env.DSH_E2E_TOGGLE_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-toggle-workspace')
let api: APIRequestContext
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'toggle 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()
})
interface FrameSample {
t: number
centerLeft: number
centerRight: number
bottomLeft: number
bottomRight: number
}
test('bottom panel tracks the center column during the right-panel toggle transition (issue #315)', async ({ page }) => {
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 })
// Dismiss onboarding (same dance as the drag lane).
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-toggle] 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(800)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
// Open the right panel (fresh sessions start collapsed).
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton).toHaveCount(1)
await expandButton.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
// Open the bottom panel too: the twin panels make the toggle exercise the
// full geometry chain (bottom panel edges follow the animated push).
const expandBottom = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(expandBottom).toHaveCount(1)
await expandBottom.click()
// Wait until the bottom panel is REALLY tracking the center column (not
// the hidden {0,0} fallback): the panel's box exists even while hidden, so
// wait for the computed visibility + a matched right edge before sampling.
await expect
.poll(async () => page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
const bottom = document.querySelector('[data-dsh-bottom-panel]')
if (col === null || col === undefined || bottom === null) return false
const cr = col.getBoundingClientRect()
const br = bottom.getBoundingClientRect()
const visible = getComputedStyle(bottom).visibility !== 'hidden'
return cr.width > 100 && visible && Math.abs(br.right - cr.right) <= 3
}), { timeout: 30_000 })
.toBe(true)
await page.waitForTimeout(500) // let both open transitions settle
// Per-frame sampler: center column (same anchor as layout.css) vs the
// bottom panel's visible edges. The loop re-resolves
// window.__toggleSamples every frame, so clearing it (sampleToggle) drops
// old frames without detaching the loop.
await page.evaluate(() => {
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement
const bottom = document.querySelector('[data-dsh-bottom-panel]')
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
const loop = (): void => {
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
const b = bottom?.getBoundingClientRect() ?? { left: 0, right: 0 }
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples.push({
t: performance.now(), centerLeft: c.left, centerRight: c.right, bottomLeft: b.left, bottomRight: b.right,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
})
const collapseButton = sidebar.getByRole('button', { name: 'Collapse sidebar' })
const expandButton2 = sidebar.getByRole('button', { name: 'Expand sidebar' })
async function sampleToggle(click: () => Promise<void>): Promise<FrameSample[]> {
// Clear accumulated frames so the sample window starts at the click.
await page.evaluate(() => {
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
})
await click()
await page.waitForTimeout(800)
const samples = await page.evaluate(() => {
const data = (window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
return data
})
return samples
}
function trackMiss(samples: FrameSample[], label: string): void {
// The bottom panel's geometry rides the center column's ResizeObserver:
// RO callbacks run AFTER layout, so a write there lands in the NEXT
// frame's paint — the panel is exactly ONE animation frame behind the
// column mid-transition (measured: bottom(t) === center(t-1)).
// Anything more is a real desync; the settled frames must agree exactly.
const valid = samples.filter(s => s.centerRight - s.centerLeft > 100)
expect(valid.length, `${label}: sampler must have captured the transition`).toBeGreaterThan(3)
let maxOneFrame = 0
for (let i = 1; i < valid.length; i++) {
maxOneFrame = Math.max(
maxOneFrame,
Math.abs(valid[i]!.bottomLeft - valid[i - 1]!.centerLeft),
Math.abs(valid[i]!.bottomRight - valid[i - 1]!.centerRight),
)
}
const settled = valid.slice(-5)
const settleMiss = Math.max(
...settled.map(s => Math.max(
Math.abs(s.bottomLeft - s.centerLeft),
Math.abs(s.bottomRight - s.centerRight),
)),
)
console.log(`[e2e-toggle] ${label}: ${valid.length} frames, max one-frame miss=${maxOneFrame.toFixed(1)}px, settle miss=${settleMiss.toFixed(1)}px`)
expect(maxOneFrame, `${label}: bottom panel must track the center column one frame behind (RO cadence)`).toBeLessThanOrEqual(4)
expect(settleMiss, `${label}: bottom panel must settle exactly on the center column`).toBeLessThanOrEqual(3)
}
const collapseSamples = await sampleToggle(() => collapseButton.click())
trackMiss(collapseSamples, 'collapse')
const expandSamples = await sampleToggle(() => expandButton2.click())
trackMiss(expandSamples, 'expand')
})