Files
apes-sidebar/tests/e2e/drag-layout.e2e.ts
T

776 lines
37 KiB
TypeScript

/**
* Drag-layout lane: the width drag must track the app shell 1:1 — the
* regression test for issue #92 ("主会话框左右抖动").
*
* The layout push reserves `padding-right: var(--dsh-sidebar-width)` inside
* AppFrame (layout.css), and layout.css disables that padding's transition
* while a drag is live via `body[data-dsh-sidebar-dragging]`. If the transition
* stays active during the drag (or the conversation lags the panel edge), the
* conversation visibly shakes at pointer cadence. This spec drives a real
* pointer drag on the width strip while a requestAnimationFrame sampler
* records, per frame:
*
* - the strip's x (the panel edge),
* - the conversation column's right edge (AppFrame's reserved padding lands
* exactly there),
* - whether `body[data-dsh-sidebar-dragging]` is set,
* - AppFrame's computed transition property/duration.
*
* Then it asserts the drag contract:
* 1. the dragging attribute is present during the drag;
* 2. AppFrame's transition is disabled (none) during the drag;
* 3. the conversation edge follows the panel edge monotonically (no
* oscillation) and 1:1 (total travel within a rounding epsilon).
*
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
* page (same contract as mount.e2e.ts, using its own workspace so the two
* lanes never race on seeding).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially
* but against the same server — never share seed paths). */
const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace')
let api: APIRequestContext
/** Seed one workspace + one session through the host's unary RPC surface
* (dual-protocol helper — see ./host-protocol.ts). */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag 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
stripX: number
convoRight: number
dragging: boolean
transitionProperty: string
transitionDuration: string
}
test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', 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-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// Dismiss whatever onboarding takeover is present (same dance as the mount
// lane), so the pointer can reach the strip without a masking overlay.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag] 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
}
// openByDefault defaults OFF: a fresh session's panel starts collapsed, and
// the collapsed layout push still writes `--dsh-sidebar-width: 0px` — so the
// geometry checks below are meaningless until the panel is expanded through
// the toggle cluster.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The layout push variable becomes live (non-zero) once the panel opens
// (session activation lags the shell render).
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)
// The width drag strip is the panel's left-edge hit strip. There is no
// dedicated hook (the skinning contract is token-driven), so locate it
// semantically among the sidebar's `cursor: col-resize` elements — the files
// window's tree-dock handle matches too, but the panel strip is always the
// LEFTMOST one (the dock sits at the panel's right edge).
const locateStrip = `(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return {
x: r.x, y: r.y, width: r.width, height: r.height,
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
innerWidth: window.innerWidth,
}
})()`
type StripBox = { x: number; y: number; width: number; height: number; varWidth: number; innerWidth: number }
// The panel SLIDES IN from the right on expand: locating the strip before
// the open transition settles captures a mid-animation box at the viewport
// edge, and the drag lands off-panel. Wait until the strip sits at the
// pushed layout edge (its right edge ≈ innerWidth - the push variable).
await expect
.poll(async () => {
const box = await page.evaluate<StripBox | null>(locateStrip)
if (box === null) return false
return Math.abs((box.x + box.width) - (box.innerWidth - box.varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate<StripBox | null>(locateStrip)
expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull()
// Instrument a per-frame sampler BEFORE the drag begins.
await page.evaluate(() => {
type Sample = FrameSample
const samples: Sample[] = []
// Same rule as the strip locator above: the LEFTMOST col-resize element
// inside the sidebar host (the tree-dock handle is further right).
const host = document.querySelector('[data-dsh-better-sidebar]')
const strip = host === null
? null
: [...host.querySelectorAll<HTMLElement>('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0]
// The conversation column is the grid item whose right edge meets the
// AppFrame padding reservation and the plugin panel.
const center = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
?? document.querySelector('#root :has(> [data-slot="conversation"])')
const frame = (document.querySelector('#root [data-dsh-frame]')
?? document.querySelector('#root > [data-slot="root"] > div')) as HTMLElement
const loop = (): void => {
const s = strip?.getBoundingClientRect() ?? { left: 0 }
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
const cs = getComputedStyle(frame)
samples.push({
t: performance.now(),
stripX: s.left,
convoRight: c.right,
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
transitionProperty: cs.transitionProperty,
transitionDuration: cs.transitionDuration,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples
})
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
await page.mouse.move(startX, startY)
await page.mouse.down()
// Drag LEFT in steps (widens the panel): the conversation edge must move
// LEFT in lockstep with the panel edge, monotonically, no oscillation.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(startX - i * 10, startY, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
await page.waitForTimeout(400)
const samples = await page.evaluate(
() => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
// The drag must actually have moved the panel (sanity: the store committed).
const first = samples.find(s => s.dragging)
const last = [...samples].reverse().find(s => s.dragging)
expect(first, 'the dragging attribute must appear during the drag').toBeDefined()
expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40)
// The conversation-column selector must have matched (the push lands on it).
expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40)
// Contract 1 + 2: while dragging, the body attribute is set and AppFrame's
// padding transition is disabled (computed `transition: none` reads as
// transition-property "none" with 0s duration; the non-dragging rule would
// compute to "padding-right" with the theme duration).
const draggingSamples = samples.filter(s => s.dragging)
expect(draggingSamples.length).toBeGreaterThan(5)
for (const sample of draggingSamples) {
expect(sample.transitionProperty, 'the padding transition must be off while dragging').toBe('none')
expect(sample.transitionDuration, 'the padding transition must be off while dragging').toBe('0s')
}
// Contract 3: monotonic, 1:1 tracking. During a leftward drag both the
// strip x and the conversation right edge decrease; allow one frame of
// rAF-batching staleness (0 delta), never a reversal.
const tracked = draggingSamples.filter(s => s.t > first!.t)
for (let i = 1; i < tracked.length; i++) {
const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX
const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight
expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2)
expect(
convoDelta,
`conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`,
).toBeLessThanOrEqual(2)
}
// Total travel in lockstep (rounding + one-frame staleness tolerance).
const stripTravel = first!.stripX - last!.stripX
const convoTravel = first!.convoRight - last!.convoRight
expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8)
})
test('a very fast width drag still commits the dragged position (no rollback on quick release)', 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-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// Dismiss whatever onboarding takeover is present (same dance as the
// main lane) so the pointer can reach the strip.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag-fast] 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
}
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').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)
const readWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
const widthBefore = await readWidth()
// The panel slides in from the right on expand: read the strip box only
// AFTER it settles at the pushed layout edge (a mid-animation read sits
// off-viewport and the drag never reaches the strip).
await expect
.poll(async () => {
const box = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
const el = [...host!.querySelectorAll('*')]
.filter(e => getComputedStyle(e).cursor === 'col-resize')
.map(e => e.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)[0]
return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
})
if (box === null) return false
const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
return { x: r.x, y: r.y, width: r.width, height: r.height }
})
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
// FAST release: a single move and an immediate up — no per-frame waits,
// so the last pointermove may never reach a requestAnimationFrame before
// the release (the exact quick-flick that used to roll back to the
// pre-drag width).
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX - 140, startY, { steps: 1 })
await page.mouse.up()
// The committed width must reflect the drag — and stay there (no rollback
// once React settles).
await expect
.poll(async () => await readWidth(), { timeout: 10_000 })
.toBeGreaterThan(widthBefore + 100)
await page.waitForTimeout(600)
expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
})
/* ── issue #247: interrupted fast drags must not roll back ───────────── */
/** Dismiss whatever onboarding takeover is present (same dance as above). */
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-drag] 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
}
}
/** Open the sidebar panel and wait for the layout push to go live. */
async function expandSidebar(page: Page, sidebar: Locator): Promise<void> {
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').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)
}
/** Locate the width drag strip (the leftmost col-resize element inside the
* sidebar host), wait for it to settle at the pushed layout edge (the
* panel slides in from the right on expand — a strip read during the
* animation sits off-viewport and no pointer event ever reaches it), then
* read its FINAL box and return a drag start point on it. */
async function settleWidthStrip(page: Page): Promise<{ startX: number; startY: number }> {
await expect
.poll(async () => {
const box = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
const el = [...host!.querySelectorAll('*')]
.filter(e => getComputedStyle(e).cursor === 'col-resize')
.map(e => e.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)[0]
return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
})
if (box === null) return false
const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
return { x: r.x, y: r.y, width: r.width, height: r.height }
})
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
return {
startX: stripBox!.x + stripBox!.width / 2,
startY: stripBox!.y + Math.min(120, stripBox!.height / 2 + 60),
}
}
/** Dispatch a synthetic pointer event (pointercancel / lostpointercapture)
* onto the width strip. Synthetic events bypass the real pointer-capture
* pipeline, which is exactly the point: the handlers must survive streams
* that end without a component-visible pointerup. */
async function dispatchPointerOnStrip(
page: Page,
type: 'pointercancel' | 'lostpointercapture',
init: { clientX?: number; clientY?: number },
): Promise<void> {
await page.evaluate(({ type, init }) => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) throw new Error('sidebar host missing')
const el = [...host.querySelectorAll<HTMLElement>('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.height > 0)
.sort((a, b) => a.r.x - b.r.x)[0]?.el
if (el === undefined) throw new Error('width drag strip not found')
el.dispatchEvent(new PointerEvent(type, {
pointerId: 1,
bubbles: true,
cancelable: true,
pointerType: 'mouse',
isPrimary: true,
...init,
}))
}, { type, init })
}
test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', 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-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
const readWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
const widthBefore = await readWidth()
const { startX, startY } = await settleWidthStrip(page)
// Ultra-fast flick whose stream the browser then CANCELS (touchpad /
// gesture interception): pointercancel carries the final position and
// lostpointercapture follows it. The double fire must commit ONCE — the
// second interrupt event used to roll the drag back to the pre-drag
// width (the abort path never marked the drag committed).
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX - 140, startY, { steps: 1 })
await dispatchPointerOnStrip(page, 'pointercancel', { clientX: startX - 140, clientY: startY })
await dispatchPointerOnStrip(page, 'lostpointercapture', {})
await expect
.poll(async () => await readWidth(), { timeout: 10_000 })
.toBeGreaterThan(widthBefore + 100)
await page.waitForTimeout(600)
expect(await readWidth(), 'the interrupted fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
// Release the real pointer stream the synthetic events bypassed.
await page.mouse.up()
})
test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', 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-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
const readWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
const widthBefore = await readWidth()
const { startX, startY } = await settleWidthStrip(page)
// A flick whose move DID reach the DOM (the rAF flushed it), followed by
// capture loss with NO coordinates (lostpointercapture does not carry a
// position). The abort must adopt the last applied size — the old code
// explicitly reverted the DOM to the pre-drag width here.
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX - 120, startY, { steps: 1 })
await page.waitForTimeout(100) // let the rAF consume the pending write
await dispatchPointerOnStrip(page, 'lostpointercapture', {})
await expect
.poll(async () => await readWidth(), { timeout: 10_000 })
.toBeGreaterThan(widthBefore + 80)
await page.waitForTimeout(600)
expect(await readWidth(), 'the no-coordinate capture loss must not roll back after settling').toBeGreaterThan(widthBefore + 80)
await page.mouse.up()
})
/* ── issue #258: the bottom panel must not flash full-width after a width
drag release ─────────────────────────────────────────────────────── */
test('bottom panel never flashes full-width after a width drag release (issue #258)', 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-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
// The bottom panel must be OPEN too — its right edge is what tracks the
// center column (and what flashes full-width on release).
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
await bottomExpand.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
const { startX, startY } = await settleWidthStrip(page)
// Per-frame sampler: the bottom panel's right edge vs the center column's
// right edge (where the layout push lands). The release path used to
// TRANSIENTLY REMOVE the push variables between the layout effect's
// cleanup and setup phases — React can yield a render frame in that gap,
// so the browser painted the push-less layout (center column full width),
// the drag-end measure cached that full-width rect, and the bottom panel
// rendered full-width while AppFrame's padding transition animated back
// to the new width. Every post-release frame must keep the two edges glued
// at the committed width: no full-width frame, no drift, no padding
// animation.
await page.evaluate(() => {
const samples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> = []
const loop = (): void => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
samples.push({
t: performance.now(),
bottomRight: bottom?.getBoundingClientRect().right ?? -1,
colRight: col?.getBoundingClientRect().right ?? -1,
varW: document.documentElement.style.getPropertyValue('--dsh-sidebar-width'),
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
iw: window.innerWidth,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __flashSamples: typeof samples }).__flashSamples = samples
})
await page.mouse.move(startX, startY)
await page.mouse.down()
for (let i = 1; i <= 10; i++) {
await page.mouse.move(startX - i * 12, startY, { steps: 2 })
await page.waitForTimeout(30)
}
await page.mouse.up()
await page.waitForTimeout(500)
const samples = await page.evaluate(
() => (window as unknown as { __flashSamples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> }).__flashSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
const lastDrag = [...samples].reverse().find(s => s.dragging)
expect(lastDrag, 'the dragging attribute must appear during the drag').toBeDefined()
const releaseIdx = samples.indexOf(lastDrag!) + 1
const release = samples[releaseIdx]!
expect(release.colRight, 'the center column must be measurable after release').toBeGreaterThan(0)
expect(release.bottomRight, 'the bottom panel must be measurable after release').toBeGreaterThan(0)
const settled = samples[samples.length - 1]!
for (const s of samples.slice(releaseIdx)) {
expect(
Math.abs(s.bottomRight - s.colRight),
`bottom panel drifted from the center column at t=${Math.round(s.t)} (bottom ${s.bottomRight} vs column ${s.colRight})`,
).toBeLessThanOrEqual(8)
const fullWidth = s.bottomRight >= s.iw - 1
expect(
fullWidth && s.varW !== '' && s.varW !== '0px',
`bottom panel flashed full-width at t=${Math.round(s.t)} (right ${s.bottomRight}, push ${s.varW})`,
).toBe(false)
expect(
Math.abs(s.colRight - settled.colRight),
`center column edge animated after release at t=${Math.round(s.t)} (${s.colRight} vs settled ${settled.colRight})`,
).toBeLessThanOrEqual(8)
}
})
test('the bottom-push anchor resolves through the shell-written center-column tag', async ({ page }) => {
// layout.css pushes the bottom panel via the center column, anchored on
// the [data-dsh-center-col] tag the Sidebar shell's locator writes onto
// the measured column node (Sidebar.tsx locate — the slot wrapper
// [data-slot="conversation"]'s parent). The tag must sit on exactly ONE
// node: a stale tag on a swapped-out node (boot swap / HMR) would leave
// the push rule anchorless or doubled.
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
await expect
.poll(async () => page.evaluate(() => document.querySelectorAll('#root [data-dsh-center-col]').length), { timeout: 90_000 })
.toBe(1)
const anchors = await page.evaluate(() => {
const tagged = document.querySelector('#root [data-dsh-center-col]')
const slotParent = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null
return {
tagged: tagged !== null,
same: tagged !== null && slotParent !== null && tagged === slotParent,
}
})
expect(anchors.tagged, 'the tagged center column must resolve').toBe(true)
expect(anchors.same, 'the tag must sit on the conversation slot wrapper\'s parent (the measured column)').toBe(true)
})
/* ── bottom-strip drag with the right panel CLOSED must not touch the host
layout — the regression test for the width-gate fix ───────────────── */
test('dragging the bottom strip while the right panel is closed keeps the host layout still', async ({ page }) => {
// The bottom drag used to write the panel's persisted width preference into
// `--dsh-sidebar-width` regardless of `panelOpen` (only the idle push
// effect gated it): with the panel closed the variable jumped 0 → ~400px on the
// first drag frame, #root lost 400px instantly, the host frame's viewport
// dropped across its 1024px auto-collapse breakpoint, and the NATIVE left
// sidebar snapped to its 56px rail — then snapped back on release. The
// panel-side DOM writes keep the raw width (the bottom panel's `right`
// derives from it); only the PUSHED width must ride the panelOpen gate.
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 right panel stays CLOSED (openByDefault off): the width variable
// must read as the collapsed push before anything happens.
const readPushWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
await expect
.poll(async () => {
const height = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
return height
}, { timeout: 90_000 })
.toBe(0)
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').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)
expect(await readPushWidth(), 'the closed right panel must push 0 width').toBe(0)
// Per-frame sampler on the HOST side: the pushed width variable, the host
// frame's inline grid template (its first track is the native left
// sidebar's width), the rendered left-column width (the grid transition
// animates mid-states, so this catches even a transient snap), and #root's
// right edge (where the width push lands).
await page.evaluate(() => {
const samples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> = []
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null
const frame = center?.parentElement ?? null
const leftCol = frame?.firstElementChild ?? null
const root = document.querySelector('#root') as HTMLElement
const loop = (): void => {
const varW = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
samples.push({
t: performance.now(),
varW: Number.isNaN(varW) ? 0 : varW,
gridCols: frame instanceof HTMLElement ? frame.style.gridTemplateColumns : '',
leftColW: leftCol?.getBoundingClientRect().width ?? -1,
rootRight: root.getBoundingClientRect().right,
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __bottomDragSamples: typeof samples }).__bottomDragSamples = samples
})
// The bottom strip is the panel's top-edge row-resize hit strip. The panel
// SLIDES DOWN into place on expand: a box read mid-animation sits above the
// settled geometry and the pointerdown lands off-strip (the drag never
// starts). Wait until the strip's center sits at innerHeight - the pushed
// height (the panel's final top edge).
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 stripBox = 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(stripBox, 'the bottom drag strip must be present (cursor: row-resize)').not.toBeNull()
// Drag the strip UP (grow the bottom panel) in steps, like a real resize.
await page.mouse.move(stripBox!.x, stripBox!.y)
await page.mouse.down()
for (let i = 1; i <= 12; i++) {
await page.mouse.move(stripBox!.x, stripBox!.y - i * 8, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
await page.waitForTimeout(500)
const samples = await page.evaluate(
() => (window as unknown as { __bottomDragSamples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> }).__bottomDragSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
const dragging = samples.filter(s => s.dragging)
expect(dragging.length, 'the dragging attribute must appear during the drag').toBeGreaterThan(5)
// The drag must actually have resized the bottom panel (sanity: the height
// push committed to a bigger value than where it started).
const heightAfter = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
expect(heightAfter).toBeGreaterThan(0)
// Contract: the pushed width stays 0 for EVERY frame — mid-drag AND after
// release. The old code let the panel's persisted preference (~400px)
// through on the first drag frame and the release commit.
for (const s of samples) {
expect(s.varW, `pushed width leaked during the bottom drag at t=${Math.round(s.t)} (got ${s.varW}px)`).toBe(0)
}
// Contract: the host layout never moves — same inline grid template, same
// rendered left-column width (the native sidebar would snap to its 56px
// rail when #root loses 400px on a 1280px viewport), same #root right edge.
const first = samples[0]!
for (const s of samples) {
expect(s.gridCols, `host grid template changed at t=${Math.round(s.t)}: ${first.gridCols} -> ${s.gridCols}`).toBe(first.gridCols)
expect(Math.abs(s.leftColW - first.leftColW), `native left column width changed at t=${Math.round(s.t)}: ${first.leftColW} -> ${s.leftColW}`).toBeLessThanOrEqual(1)
expect(Math.abs(s.rootRight - first.rootRight), `#root right edge changed at t=${Math.round(s.t)}: ${first.rootRight} -> ${s.rootRight}`).toBeLessThanOrEqual(1)
}
})