fix: preserve desktop shell layout with side card

This commit is contained in:
laoyh
2026-09-02 23:37:40 +08:00
committed by Menghuan1918
parent dde0923980
commit e12927e8a9
6 changed files with 186 additions and 60 deletions
+112
View File
@@ -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([])
})
+22 -21
View File
@@ -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 }> = []