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).
129 lines
6.8 KiB
TypeScript
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([])
|
|
})
|