fix: preserve desktop shell layout with side card
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/** Desktop layout regression: opening the right panel must not make Harness enter narrow mode. */
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { PAGE_URL } from './host'
|
||||
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_DESKTOP_WORKSPACE
|
||||
?? (process.env.DSH_E2E_WORKSPACE === undefined
|
||||
? join(homedir(), 'dsh-e2e-desktop-layout-workspace')
|
||||
: `${process.env.DSH_E2E_WORKSPACE}-desktop-layout`)
|
||||
|
||||
async function dismissOnboarding(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later|继续|稍后配置)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let dismissed = false
|
||||
for (const name of [/^(Continue|继续)$/, /^(Configure later|稍后配置)$/]) {
|
||||
const button = page.getByRole('button', { name }).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
await button.click()
|
||||
dismissed = true
|
||||
await expect(button).toBeHidden()
|
||||
}
|
||||
if (!dismissed) return
|
||||
}
|
||||
throw new Error('onboarding takeovers did not settle after visible dismissal')
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
})
|
||||
|
||||
test('right panel keeps desktop session actions in their header positions', async ({ page }) => {
|
||||
const pageErrors: string[] = []
|
||||
const consoleErrors: string[] = []
|
||||
const requestFailures: string[] = []
|
||||
const badResponses: string[] = []
|
||||
page.on('pageerror', error => pageErrors.push(error.stack ?? error.message))
|
||||
page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) })
|
||||
page.on('requestfailed', request => requestFailures.push(`${request.method()} ${request.url()} ${request.failure()?.errorText ?? 'unknown'}`))
|
||||
page.on('response', response => { if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`) })
|
||||
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await dismissOnboarding(page)
|
||||
|
||||
await page.getByRole('button', { name: /^(Choose workspace|选择工作区)$/ }).click()
|
||||
const picker = page.getByRole('dialog', { name: /^(Select Workspace Directory|选择工作区目录)$/ })
|
||||
await expect(picker).toBeVisible()
|
||||
await picker.getByRole('button', { name: /^(Edit path|编辑路径)$/ }).click()
|
||||
const pathInput = picker.getByRole('textbox', { name: /^(Edit path|编辑路径)$/ })
|
||||
await pathInput.fill(WORKSPACE_PATH)
|
||||
await pathInput.press('Enter')
|
||||
await expect(pathInput).toBeHidden()
|
||||
await picker.getByRole('button', { name: /^(Open|打开)$/ }).click()
|
||||
|
||||
// Target the ACTIVE composer by its accessible name. DSH 0.1.2-alpha hosts
|
||||
// keep an INERT `data-composer-input` "Choose workspace" textbox (with
|
||||
// contenteditable="false") later in DOM order than the real composer — a
|
||||
// bare `getByRole('textbox').last()` resolved to that one and fill() threw
|
||||
// ("not an <input>, <textarea> or [contenteditable] element").
|
||||
const composer = page.getByRole('textbox', { name: /^(Describe what you want to build|描述你想要构建)/ })
|
||||
await expect(composer).toBeVisible()
|
||||
await composer.fill('Create a desktop side-card layout test session.')
|
||||
await page.getByRole('button', { name: /^(Send message|发送消息)$/ }).click()
|
||||
|
||||
const root = page.locator('#root')
|
||||
const frame = page.locator('#root [data-dsh-frame], #root > [data-slot="root"] > div').first()
|
||||
const appSidebarExpanded = root.getByRole('button', { name: /^(Collapse sidebar|收起侧边栏)$/ })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
const sessionLog = page.getByRole('button', { name: 'Session log', exact: true })
|
||||
const sessionLogLabel = sessionLog.getByText('Session log', { exact: true })
|
||||
await expect(sessionLogLabel, 'the desktop session-log action starts as a text button').toBeVisible({ timeout: 30_000 })
|
||||
await expect(appSidebarExpanded, 'the desktop frame starts with its app sidebar expanded').toBeVisible()
|
||||
const beforeRoot = await root.boundingBox()
|
||||
const beforeFrame = await frame.boundingBox()
|
||||
expect(beforeRoot).not.toBeNull()
|
||||
expect(beforeFrame).not.toBeNull()
|
||||
|
||||
await sidebar.getByRole('button', { name: /^(Expand sidebar|展开侧边栏)$/ }).click()
|
||||
const panel = page.locator('[data-dsh-panel]:not([data-dsh-bottom-panel])')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const center = page.locator('#root [data-dsh-frame] > [data-pane="conversation"], #root :has(> [data-slot="conversation"])').first()
|
||||
const [centerBox, panelBox] = await Promise.all([center.boundingBox(), panel.boundingBox()])
|
||||
if (centerBox === null || panelBox === null) return Number.POSITIVE_INFINITY
|
||||
return Math.abs(centerBox.x + centerBox.width - panelBox.x)
|
||||
}, { timeout: 30_000 })
|
||||
.toBeLessThanOrEqual(2)
|
||||
|
||||
const afterRoot = await root.boundingBox()
|
||||
const afterFrame = await frame.boundingBox()
|
||||
expect(afterRoot).not.toBeNull()
|
||||
expect(afterFrame).not.toBeNull()
|
||||
expect(Math.abs(afterRoot!.width - beforeRoot!.width), 'opening the plugin panel must not shrink #root').toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(afterFrame!.width - beforeFrame!.width), 'opening the plugin panel must not shrink AppFrame').toBeLessThanOrEqual(1)
|
||||
await expect(appSidebarExpanded, 'a desktop window must not enter the host narrow layout').toBeVisible()
|
||||
await expect(sessionLogLabel, 'the session-log action must keep its desktop label').toBeVisible()
|
||||
expect(await sessionLog.evaluate(element => getComputedStyle(element).position), 'the session-log action must stay in the header flow').not.toBe('fixed')
|
||||
|
||||
const [sessionLogBox, panelBox] = await Promise.all([sessionLog.boundingBox(), panel.boundingBox()])
|
||||
expect(sessionLogBox).not.toBeNull()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(sessionLogBox!.width, 'the desktop session-log action must keep its full button width').toBeGreaterThanOrEqual(110)
|
||||
expect(sessionLogBox!.x + sessionLogBox!.width, 'the session-log action must not overlap the plugin panel chrome').toBeLessThanOrEqual(panelBox!.x - 8)
|
||||
expect(pageErrors).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
expect(requestFailures).toEqual([])
|
||||
expect(badResponses).toEqual([])
|
||||
})
|
||||
@@ -2,23 +2,23 @@
|
||||
* 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
|
||||
* 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 (`#root`'s margin push lands
|
||||
* - the conversation column's right edge (AppFrame's reserved padding lands
|
||||
* exactly there),
|
||||
* - whether `body[data-dsh-sidebar-dragging]` is set,
|
||||
* - `#root`'s computed transition property/duration.
|
||||
* - AppFrame'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;
|
||||
* 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).
|
||||
*
|
||||
@@ -162,15 +162,16 @@ test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', as
|
||||
: [...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
|
||||
// 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(root)
|
||||
const cs = getComputedStyle(frame)
|
||||
samples.push({
|
||||
t: performance.now(),
|
||||
stripX: s.left,
|
||||
@@ -212,15 +213,15 @@ test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', as
|
||||
// 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
|
||||
// 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 "margin-right" with the theme duration).
|
||||
// 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 margin transition must be off while dragging').toBe('none')
|
||||
expect(sample.transitionDuration, 'the margin transition must be off while dragging').toBe('0s')
|
||||
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
|
||||
@@ -552,9 +553,9 @@ test('bottom panel never flashes full-width after a width drag release (issue #2
|
||||
// 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
|
||||
// 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 }> = []
|
||||
|
||||
Reference in New Issue
Block a user