The bottom drag applied the panel's persisted width preference to --dsh-sidebar-width regardless of panelOpen (only the idle push effect gated it through layoutPushSize). With the right panel closed, the first drag frame jumped the variable 0 → ~400px: #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 bottom strip is the only drag reachable with the panel closed, which is why only it showed. applyDrag now rides the same gate as the push effect for the PUSHED width; the panel-side DOM writes (panelRef/bottomRef/lastDragSize) keep the raw width — the bottom panel's right edge derives from it. The e2e regression pins the pose: panel closed + bottom open, drag the strip, and every sampled frame must keep the pushed width at 0, the host grid template, the left column width and #root's right edge unchanged. Verified red on the old code (leaked 504px).
782 lines
37 KiB
TypeScript
782 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 squeezes `#root` via `margin-right: var(--dsh-sidebar-width)`
|
|
* (layout.css), and layout.css disables that margin'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 (`#root`'s margin push lands
|
|
* exactly there),
|
|
* - whether `body[data-dsh-sidebar-dragging]` is set,
|
|
* - `#root`'s computed transition property/duration.
|
|
*
|
|
* Then it asserts the drag contract:
|
|
* 1. the dragging attribute is present during the drag;
|
|
* 2. `#root`'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: the grid item the layout push squeezes (the
|
|
// same nth-child(2) layout.css targets for the vertical push; its right
|
|
// edge is where the width push lands).
|
|
const center = document.querySelector('#root > div[data-slot="root"] > div > div:nth-child(2)')
|
|
const root = document.querySelector('#root') as HTMLElement
|
|
const loop = (): void => {
|
|
const s = strip?.getBoundingClientRect() ?? { left: 0 }
|
|
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
|
|
const cs = getComputedStyle(root)
|
|
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 #root's
|
|
// margin transition is disabled (computed `transition: none` reads as
|
|
// transition-property "none" with 0s duration; the non-dragging rule would
|
|
// compute to "margin-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 margin transition must be off while dragging').toBe('none')
|
|
expect(sample.transitionDuration, 'the margin 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 #root's margin 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 margin
|
|
// 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 composite selectors (at least one; same element when both)', async ({ page }) => {
|
|
// layout.css pushes the bottom panel via the center column. The selector
|
|
// is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming)
|
|
// and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST
|
|
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
|
|
// the contract is "at least one resolves", and when both resolve they
|
|
// must hit the SAME element (otherwise the push would land twice).
|
|
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
|
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
|
const anchors = await page.evaluate(() => {
|
|
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
|
|
const b = document.querySelector('#root :has(> [data-slot="conversation"])')
|
|
const frame = document.querySelector('#root [data-dsh-frame]')
|
|
return {
|
|
a: a !== null,
|
|
b: b !== null,
|
|
same: a !== null && b !== null && a === b,
|
|
frameChildren: frame !== null
|
|
? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`)
|
|
: [],
|
|
}
|
|
})
|
|
expect(
|
|
anchors.a || anchors.b,
|
|
`at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`,
|
|
).toBe(true)
|
|
if (anchors.a && anchors.b) {
|
|
expect(anchors.same, 'both selectors must hit the SAME center-column element').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)
|
|
}
|
|
})
|