Files
apes-sidebar/tests/e2e/desktop-layout.e2e.ts
T
Menghuan1918 ba0114749f feat(dsh): adapt to DSH 0.1.5-alpha.1
Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
2026-09-09 11:15:30 +08:00

129 lines
6.8 KiB
TypeScript

/** 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 APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } 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`)
let api: APIRequestContext
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(async () => {
mkdirSync(WORKSPACE_PATH, { recursive: true })
// Seed one workspace + one session through the host's own RPC surface (the
// calls the UI makes) instead of driving the workspace picker: DSH 0.1.5
// ships BOTH a browser directory dialog and an OS-native chooser and picks
// one by platform, so a UI-driven flow only works on hosts whose chooser
// renders in the page.
api = await createHostApi()
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
})
test.afterAll(async () => {
await api?.dispose()
})
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)
// The seeded blank session opens with its workspace already attached (the
// chip shows the workspace title), so the hero composer is live without
// touching the workspace picker at all. One message gives the header its
// real actions, which this spec measures.
const composer = page.getByRole('textbox', { name: /^(Describe what you want to build|描述你想要构建)/ })
await expect(composer).toBeVisible()
// DSH 0.1.5's composer is a Lexical contenteditable: fill() writes the DOM
// but not the editor model, so the send button would stay disabled — type
// real keys instead.
await composer.click()
await page.keyboard.type('Create a desktop side-card layout test session.')
const send = page.getByRole('button', { name: /^(Send message|发送消息)$/ })
await expect(send).toBeEnabled({ timeout: 30_000 })
await send.click()
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 })
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]')
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()
// The header action's own width is the baseline: the assertion below is
// "the panel does not squeeze it", not a host-styling constant.
const beforeSessionLog = await sessionLog.boundingBox()
expect(beforeSessionLog).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(beforeSessionLog!.width - 1)
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([])
})