- 文件页 chrome 行统一:路径行与树搜索行同为 41px 高(路径输入框改 border-box 28px 总高)、gap 统一 6px、内边距统一 6px 8px - 树体侧距 6→8px 对齐 chrome 行;markdown 预览 10/14→12/16(与错误 文案块一致);CodeMirror scroller 新增同款 12/16 内边距(预览/编辑 切换文字位置不跳);截断横幅 12→8px - 浮窗默认尺寸 480×360 → 390×780(1:2 手机竖屏比例),创建时先按 视口减 24px 钳制再居中,矮视口得到更矮的窗而非溢出 - 测试/文档同步:state/free-window 预期重算(含 clampMove 回推的 y=24 语义)、e2e 断言宽度上界、AGENTS §7.5 与设计文档
259 lines
13 KiB
TypeScript
259 lines
13 KiB
TypeScript
/**
|
|
* Free-window lane (v0.16.0): against a real `dsh web` instance with the
|
|
* npm-packed plugin mounted (scripts/e2e-mount.sh, DSH_E2E_URL).
|
|
*
|
|
* 1. The tab context menu's "Move to Free Window" floats the seeded Files
|
|
* tab: `[data-dsh-float-window]` mounts and the tab leaves the strip.
|
|
* 2. The header drag moves the window (geometry committed to the session
|
|
* state).
|
|
* 3. A reload restores the window from the persisted session state.
|
|
* 4. The header context menu's "Dock Back to Sidebar" returns the tab to
|
|
* the workbench and removes the window.
|
|
* 5. The drag-out gesture itself (HTML5 DnD onto the conversation column)
|
|
* floats another tab with the hint overlay showing mid-drag.
|
|
*
|
|
* Crash discipline mirrors the mount lane: pageerror / plugin console errors
|
|
* / dsh-better-sidebar strips fail the test at every step.
|
|
*/
|
|
import { mkdirSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { test, expect, request, type APIRequestContext, type Page } from '@playwright/test'
|
|
|
|
const BASE_URL = process.env.DSH_E2E_URL
|
|
if (!BASE_URL) {
|
|
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
|
}
|
|
|
|
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-float-workspace')
|
|
|
|
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
|
|
|
|
let api: APIRequestContext
|
|
|
|
async function seedSession(): Promise<void> {
|
|
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
|
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
|
data: { type: 'client-request', rpcId: 'e2e-float-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
|
})
|
|
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
|
const workspaceBody = (await workspace.json()) as {
|
|
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
|
}
|
|
expect(workspaceBody.result.ok).toBe(true)
|
|
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
|
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
|
data: { type: 'client-request', rpcId: 'e2e-float-session', method: 'session.create', payload: { workspaceId } },
|
|
})
|
|
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
|
const sessionBody = (await session.json()) as {
|
|
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
|
|
}
|
|
expect(sessionBody.result.ok).toBe(true)
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
api = await request.newContext({ baseURL: BASE_URL })
|
|
await seedSession()
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await api?.dispose()
|
|
})
|
|
|
|
/** Dismiss DSH's first-run takeovers (onboarding modal etc.): stacked masks
|
|
* would intercept every subsequent pointer interaction. They mount only
|
|
* after the settings join resolves (seconds after load — also after a
|
|
* reload), so first wait bounded for one to appear, then sweep until none
|
|
* remain. Mirrors the mount lane's dance. */
|
|
async function dismissTakeovers(page: Page): Promise<void> {
|
|
try {
|
|
await expect
|
|
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
|
.toBeGreaterThan(0)
|
|
} catch {
|
|
return // A DSH build without onboarding takeovers: nothing to sweep.
|
|
}
|
|
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; the next round retries.
|
|
}
|
|
}
|
|
if (!dismissed) break
|
|
}
|
|
}
|
|
|
|
/** Load the shell, dismiss onboarding takeovers, and expand the panel.
|
|
* Returns the sidebar host locator. (An arrow const, not a hoisted function
|
|
* declaration, so the module-level BASE_URL narrowing carries in.) */
|
|
const bootExpanded = async (page: Page): Promise<ReturnType<Page['locator']>> => {
|
|
const pageErrors: string[] = []
|
|
const consoleErrors: string[] = []
|
|
page.on('pageerror', (error) => pageErrors.push(String(error)))
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') consoleErrors.push(message.text())
|
|
})
|
|
await page.goto(BASE_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 dismissTakeovers(page)
|
|
await expect(sidebar.locator('[title]').first()).toBeAttached({ timeout: 90_000 })
|
|
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
|
if ((await expandButton.count()) === 1) await expandButton.click()
|
|
await expect
|
|
.poll(async () => (
|
|
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
|
), { timeout: 90_000 })
|
|
.not.toBe('')
|
|
expect(pageErrors, 'page errors during boot').toEqual([])
|
|
expect(consoleErrors.filter(text => text.includes('dsh-better-sidebar')), 'plugin console errors during boot').toEqual([])
|
|
return sidebar
|
|
}
|
|
|
|
/** The crash assertions shared by every step (mount-lane discipline). */
|
|
async function assertNoCrash(page: Page): Promise<void> {
|
|
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
|
const stripTexts = await sidebar.locator('div').evaluateAll(
|
|
(nodes, patterns) => nodes.filter((node) => {
|
|
const text = (node.textContent ?? '').trim()
|
|
return patterns.some((pattern) => pattern.test(text))
|
|
}).map((node) => (node.textContent ?? '').trim()),
|
|
CRASH_STRIP_PATTERNS,
|
|
)
|
|
expect(stripTexts, 'a dsh-better-sidebar error strip is present').toEqual([])
|
|
}
|
|
|
|
/** The conversation column's viewport rect — the production drag-out target
|
|
* (Sidebar.tsx): the [data-slot="conversation"] WRAPPER may be zero-size (a
|
|
* slot pass-through), so the sized element is its parent column. */
|
|
async function conversationBox(page: Page): Promise<{ x: number; y: number; width: number; height: number } | null> {
|
|
return await page.evaluate(() => {
|
|
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
|
if (col === null || col === undefined) return null
|
|
const rect = col.getBoundingClientRect()
|
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
})
|
|
}
|
|
|
|
/** The float's content cell must hand its child a real width (the
|
|
* column-flex contract): a collapsed 0px cell paints nothing — the tab is
|
|
* "in" the window but invisible. */
|
|
async function assertContentPainted(page: Page, label: string): Promise<void> {
|
|
const width = await page.evaluate(() => {
|
|
const cell = document.querySelector('[data-dsh-float-window] [class*="floatContent"]')
|
|
const child = cell?.firstElementChild
|
|
return child === null || child === undefined ? -1 : Math.round(child.getBoundingClientRect().width)
|
|
})
|
|
expect(width, `${label}: the floated tab's content must have real width`).toBeGreaterThan(200)
|
|
}
|
|
|
|
test('float a tab, move the window, reload restores it, dock it back', async ({ page }) => {
|
|
const sidebar = await bootExpanded(page)
|
|
|
|
// 1. The tab context menu floats the seeded Files home tab.
|
|
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
|
|
await expect(filesTab).toHaveCount(1)
|
|
await filesTab.click({ button: 'right' })
|
|
const floatItem = page.getByRole('menuitem', { name: 'Move to Free Window' }).first()
|
|
await expect(floatItem, 'the tab context menu must offer "Move to Free Window"').toHaveCount(1)
|
|
await floatItem.click()
|
|
const floatWindow = sidebar.locator('[data-dsh-float-window]')
|
|
await expect(floatWindow).toHaveCount(1, { timeout: 10_000 })
|
|
// The tab left the strip with the window born over the conversation area
|
|
// (center), well clear of the panel's right edge.
|
|
await assertContentPainted(page, 'context-menu float')
|
|
const leftBefore = await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left))
|
|
expect(Number.isFinite(leftBefore), `style.left must be numeric, got ${leftBefore}`).toBe(true)
|
|
expect(leftBefore).toBeGreaterThan(0)
|
|
expect(leftBefore).toBeLessThan(1440 - 390)
|
|
await assertNoCrash(page)
|
|
|
|
// 2. The header drag moves the window (pointer capture + commit).
|
|
const header = floatWindow.locator('[class*="floatHeader"]')
|
|
const box = await header.boundingBox()
|
|
const conv = await conversationBox(page)
|
|
expect(box, 'the float header must have geometry').not.toBeNull()
|
|
expect(conv, 'the conversation column must have geometry').not.toBeNull()
|
|
const dragToX = conv!.x + conv!.width * 0.35
|
|
const dragToY = conv!.y + conv!.height * 0.35
|
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
|
await page.mouse.down()
|
|
await page.mouse.move(dragToX, dragToY, { steps: 12 })
|
|
await page.mouse.up()
|
|
await expect
|
|
.poll(async () => (
|
|
await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left))
|
|
), { timeout: 10_000 })
|
|
.not.toBe(leftBefore)
|
|
await assertNoCrash(page)
|
|
|
|
// 3. A reload restores the window from the persisted session state. Give
|
|
// the store's debounced persist a beat first: reloading the instant the
|
|
// drag poll resolves can beat the 200ms flush timer.
|
|
await page.waitForTimeout(500)
|
|
await page.reload({ waitUntil: 'domcontentloaded' })
|
|
await dismissTakeovers(page)
|
|
const sidebar2 = page.locator('[data-dsh-better-sidebar]')
|
|
await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(1, { timeout: 90_000 })
|
|
await assertNoCrash(page)
|
|
|
|
// 4. The header context menu docks the tab back into the sidebar.
|
|
const header2 = sidebar2.locator('[data-dsh-float-window] [class*="floatHeader"]')
|
|
await header2.click({ button: 'right' })
|
|
const dockItem = page.getByRole('menuitem', { name: 'Dock Back to Sidebar' }).first()
|
|
await expect(dockItem, 'the float header context menu must offer "Dock Back to Sidebar"').toHaveCount(1)
|
|
await dockItem.click()
|
|
await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(0, { timeout: 10_000 })
|
|
await expect(sidebar2.locator('[title="Files"][draggable="true"]').first()).toHaveCount(1)
|
|
await assertNoCrash(page)
|
|
})
|
|
|
|
test('dragging a tab onto the conversation area floats it (drag-out gesture)', async ({ page }) => {
|
|
const sidebar = await bootExpanded(page)
|
|
|
|
// Open a terminal through the + menu (a second tab to drag out; the Files
|
|
// home tab keeps the pane non-empty afterwards). The terminal tab's title
|
|
// is the host shell name once resolved (fallback "Terminal"), so address
|
|
// it by POSITION: the strip's second tab.
|
|
await sidebar.getByRole('button', { name: 'New tab' }).first().click()
|
|
const terminalItem = page.getByRole('menuitem', { name: 'Terminal' }).first()
|
|
await expect(terminalItem).toHaveCount(1)
|
|
await terminalItem.click()
|
|
const stripTabs = sidebar.locator('[class*="tabList"] > [draggable="true"]')
|
|
await expect(stripTabs, 'the strip must hold the seeded Files tab plus the terminal').toHaveCount(2, { timeout: 30_000 })
|
|
const terminalTab = stripTabs.nth(1)
|
|
const terminalTitle = (await terminalTab.getAttribute('title')) ?? 'Terminal'
|
|
|
|
// Drag it onto the conversation column: the hint overlay appears mid-drag
|
|
// and the drop floats the tab. ([class*="floatDropHint"] matches BOTH the
|
|
// overlay and its label span — expect the pair.)
|
|
const tabBox = await terminalTab.boundingBox()
|
|
const conv = await conversationBox(page)
|
|
expect(tabBox, 'the terminal tab must have geometry').not.toBeNull()
|
|
expect(conv, 'the conversation column must have geometry').not.toBeNull()
|
|
const hint = sidebar.locator('[class*="floatDropHint"]')
|
|
await page.mouse.move(tabBox!.x + tabBox!.width / 2, tabBox!.y + tabBox!.height / 2)
|
|
await page.mouse.down()
|
|
await page.mouse.move(conv!.x + conv!.width / 2, conv!.y + conv!.height / 2, { steps: 12 })
|
|
await expect(hint, 'the drag-out hint must mark the conversation column mid-drag').toHaveCount(2, { timeout: 10_000 })
|
|
await page.mouse.up()
|
|
const floatWindow = sidebar.locator('[data-dsh-float-window]')
|
|
await expect(floatWindow, 'the drop must float the tab').toHaveCount(1, { timeout: 10_000 })
|
|
await expect(hint).toHaveCount(0)
|
|
// The terminal tab left the strip (only Files remains); the window shows
|
|
// its title.
|
|
await expect(stripTabs).toHaveCount(1)
|
|
await expect(floatWindow.locator('[class*="floatTitle"]')).toHaveText(terminalTitle)
|
|
await assertContentPainted(page, 'drag-out float')
|
|
await assertNoCrash(page)
|
|
})
|