Files
apes-sidebar/tests/e2e/float-window.e2e.ts
T
Menghuan1918 6cfe7a736f feat: adapt to DSH 0.1.2-alpha.1 (Remote gateway + token auth + MarkdownText labels)
Dual-version compatibility: the plugin now works identically on
0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified against a
source build of the dsh-v0.1.2-alpha.1 tag: mount smoke 14/14 + aggregate
double-mount; 0.1.1-rc.2 re-verified 14/14).

- MarkdownText: alpha.1 renamed the flat optional `codeLabels` prop to a
  required nested `labels: { code: {copyLabel, copiedLabel}, footnotes }`;
  the old prop alone crashes fence rendering with "reading 'code'". All
  four render sites (mermaid / MarkdownHtml / TextEditor / SideChatView)
  now go through src/client/markdown-labels.tsx (dual-shape object passed
  under both prop names).
- e2e mount lanes speak both host dialects (tests/e2e/host-protocol.ts +
  host.ts, unit-locked by tests/e2e-host-protocol.spec.ts):
  - one-time-token browser auth: the launch URL grep extends past the
    `/?token=` query (truncating at `/` loses the token), goto navigates
    the token URL (browser cookie exchange), and stamped navigations
    pre-seed the context cookie via addCookies — the exchange 303 drops
    sibling query params;
  - Remote gateway: dot endpoints first, slash endpoints on 404 fallback,
    with args keyed by the controller parameter name (workspace/create ->
    {request}, session/list -> {_request}; verified on a live host).
- package.json: drop the @deepseek-ai/dsh-client-runtime peer (package
  removed upstream in alpha.1; zero references in the source — the chunk
  externals entry stays and is resolution-tolerant).
- scripts/e2e-mount.sh: fix a latent set -u crash in the npx fallback
  branch ($DSH_CMD followed by a full-width comma parsed as part of the
  variable name).
- CI pin stays at @deepseek-ai/dsh@0.1.1-rc.2 until alpha.1 publishes to
  npm (comment updated); README/AGENTS document the new contracts.
- Version lockstep bumped to 0.17.1.
2026-08-28 04:20:36 +08:00

240 lines
12 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, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
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 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()
})
/** 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 PAGE_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(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 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)
})