Files
apes-sidebar/tests/e2e/mount.e2e.ts
T
Atlas Zeng 6ee1ba11d0 feat(editor): 文件窗口与资源管理器二合一 + 声明式设置 select 行 (#151)
* feat(editor): merged explorer mode — path input, docked file tree, global name search

New editorExplorer pref (default on, editor card gear): the editor tab gains
a path-input header and a toggleable right-docked tree panel (per-tab state
in tab.meta.treeOpen; file tabs default closed, the seeded path-less home
tab defaults open). Fresh sessions seed that empty editor tab instead of
the explorer tab when merged mode is on; the standalone explorer tab stays
in the + menu unchanged.

- Extract FileTree.tsx from ExplorerView (shared by both hosts)
- Host fs.search route: recursive case-insensitive name search rooted at
  the session cwd, budget-capped (200 matches / 100k visited), skips .git,
  never descends symlink dirs
- EditorHost reads the pref reactively (boolean snapshot) and keeps the
  pre-merge layout verbatim when off

* feat(settings): select row type for declarative settings; editorExplorer as iconed select

- SidebarSettingToggle gains type 'select' with options {value,title,desc,icon}
  and multi (default false; stores the picked values as an array in options
  order). Any option carrying an icon renders big-icon option cards in the
  dropdown and the icon in the closed anchor; icon-less rows are one-line text.
- SelectRow in SideCardSection rides the primitives Menu (portal); single-pick
  commits and closes, multi toggles and stays open. Wired for both host-prefs
  toggles and plugin-owned pluginToggles via the new onSelectValue seam.
- The editorExplorer merged-mode pref is now an iconed select (merged vs
  separate) instead of a switch; the stored value stays boolean.
- Advertise the capability as 'settingSelect' in SIDEBAR_FEATURES.

* feat(editor): unify explorer into the editor surface, resizable tree dock, hoisted toolbar

- One tree implementation, two presentations: the shared TreePanel (global
  name search + refresh + FileTree) docks as the editor tab's right panel
  and — in merged mode — fills the explorer tab's window; the pref-off
  explorer keeps its legacy header shell.
- The docked panel drag-resizes from its left edge (160-480px clamp),
  persisted as tab.meta.treeWidth alongside treeOpen.
- The text editor's preview/edit/save toolbar moves into the path-input
  header row: FileViewerProps gains internal toolbar fields ('host' mode
  skips the viewer's own row, reports EditorToolbarState and registers
  EditorToolbarControls); non-merged mode is byte-identical to before.

* feat(editor): single files window with in-place switching; explorer descriptor removed

- editor tab replaces explorer on the + menu (order 10, Files); persisted
  explorer tabs migrate to the editor home tab via sanitizeState
- editorExplorer now only controls open behavior: merged = in-place
  updateTab, independent = openSidebarFile; both modes share the merged
  chrome (path input + hoisted preview/edit/save toolbar + tree toggle)
- file tree context menu gains 'open in new tab' / 'open to the side'
  (mintTabId avoids the id safety net; insertLeafAt splits right)
- tree dock resize uses pointer capture (touch-action none, 6px handle)
- defaults: openByDefault off, sidebar width 35%
- file tree clips horizontal overflow (no sideways scroll)
- e2e: mount lane arms the editor-chunk wait before goto and clicks the
  tree row's left edge (hover @-reference button sits mid-row); drag lane
  expands the panel first (collapsed push writes 0px), waits for the
  slide-in to settle, and picks the leftmost col-resize strip; 'Files'
  title assertions require draggable to skip the bottom panel's welcome
  cards

* feat(editor): split mode turns the path-less window into the standalone explorer

In independent (split) mode a path-less files window renders ONLY the
tree panel (search + FileTree, full-window form) — no path input, no
hoisted toolbar, no tree toggle. File clicks open per-path editor tabs
(full chrome, tree docked closed by default) through openSidebarFile,
unchanged. Merged mode is untouched (chrome + docked tree, in-place
switching). TreePanel's previously reserved 'full' prop now has its
consumer.
2026-08-17 15:00:03 +08:00

242 lines
12 KiB
TypeScript

/**
* Headless-render mount lane: prove the npm-packed plugin mounts into a real
* `dsh web` instance and renders without crashing the shell.
*
* The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web`
* (with the plugin mounted through the official `dsh plugin add` channel) and
* injects the base URL via `DSH_E2E_URL`. This spec:
*
* 1. seeds one workspace + one session through the host's own RPC surface
* (the same `workspace.create` / `session.create` calls the UI makes),
* so the sidebar has a real session to render;
* 2. loads the page in headless Chromium and asserts the shell and the
* plugin's `[data-dsh-better-sidebar]` host mount;
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
* including the lazily-fetched terminal chunk — and then opens a seeded
* file through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while a response wait
* armed before goto proves the lazily-fetched editor chunk
* (client-editor.js) loaded.
*
* Deterministic by construction: every wait is on a DOM/network marker, the
* suite is serial (one server instance), and any crash trips the very next
* assertion.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } 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)')
}
/** Workspace the sidebar renders against (created by the lane's seeding). */
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
/** A file seeded into the workspace, opened through the Files window's tree to
* exercise the file-open path (editor chunk = client-editor.js). */
const SEEDED_FILE = 'hello.txt'
/**
* The plugin's crash markers. The client mounts inside an error boundary that
* renders a strip whose text starts with these prefixes instead of crashing
* (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx).
*/
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
let api: APIRequestContext
/** Seed one workspace + one session (plus a file for the editor-chunk probe)
* through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-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-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => {
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())
})
// Load the shell. The app renders into #root; the plugin appends its own
// [data-dsh-better-sidebar] host once its client half activates.
//
// The editor chunk (client-editor.js) loads as soon as ANY files-window tab
// renders — the seeded home tab mounts the moment the panel expands, long
// before the tree click below — so the response wait must be armed BEFORE
// goto, or it misses the fetch and times out.
const editorChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/editor.js'),
{ timeout: 120_000 },
)
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 })
// A keyless boot stacks onboarding takeovers that mask the whole shell: a
// versioned welcome notice ("Continue", persists its acknowledgement to
// settings) and, once that is acknowledged, a provider-config dialog
// ("Configure later", session-only — always present while no credential is
// configured). Both mount only after the settings join resolves. Wait
// (bounded) for one to appear; a DSH build without onboarding proceeds
// straight to the sweep.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
}
// Dismiss whatever takeover is present, in any stacking order, until none
// remain — a masked click is retried next round instead of failing.
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 the takeover stacked above it; the next round tries the
// other button first.
}
}
if (!dismissed) break
}
// The seeded session must give the sidebar a session scope: without it the
// shell renders a disabled toggle cluster and the tab sweep is impossible.
const tabBar = sidebar.locator('[title]')
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
// openByDefault defaults OFF: a fresh session's panel starts collapsed.
// Expand it through the toggle cluster before the layout push can apply.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The skinning contract is token-driven (AGENTS.md §8): the panels consume
// `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with
// no per-skin code. The layout push variable must be live once the panel
// mounts (its absence would mean the panel never opened with the session).
await expect
.poll(async () => (
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
), { timeout: 90_000 })
.not.toBe('')
// Crash-marker assertions shared by every step.
const assertNoCrash = async (): Promise<void> => {
await expect
.poll(async () => pageErrors, { timeout: 5_000 })
.toEqual([])
const strips = await sidebar.locator('div').evaluateAll(
(nodes, patterns) => nodes.filter((node) => {
const text = (node.textContent ?? '').trim()
return patterns.some((pattern) => pattern.test(text))
}).length,
CRASH_STRIP_PATTERNS,
)
expect(strips, 'a dsh-better-sidebar error strip is present in the sidebar').toBe(0)
}
// Sweep every built-in tab through the "+" menu (the sidebar's own open-tab
// affordance, reachable from any pane state). Each open may fetch a lazy
// chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a
// real viewer — the highest-risk crash surfaces. The pinned plugin must
// offer every listed built-in: a missing or renamed descriptor is a real
// regression and fails the lane loudly instead of silently narrowing the
// sweep. A failure anywhere surfaces as a pageerror or a console error,
// both of which the next assertion sees.
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
for (const title of BUILTIN_TABS) {
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
await item.click()
// Let the activation commit (including any lazy-chunk fetch) before the
// crash assertions run.
await page.waitForTimeout(1_500)
await assertNoCrash()
}
// The editor chunk (client-editor.js) only loads when a files-window tab
// renders. Exercise the file-open path explicitly through the Files window's
// own tree: the seeded home tab ("Files") is already open with its tree
// panel pinned — activate it from the tab strip, open the seeded file, and
// require the chunk round-trip (armed before goto), so a missing/corrupt
// editor chunk fails the lane.
// Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden)
// bottom panel's empty-pane welcome cards repeat the + menu labels with
// `title="Files"`, so a bare `[title="Files"]` match is ambiguous.
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1)
await filesTab.click()
// Inactive tabs stay mounted (display:none); only the ACTIVE files
// window's embedded tree is visible — match the visible row.
const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
// Click near the row's LEFT edge: hovering reveals an @-reference button at
// the row's right end, and a center click on a narrow dock lands on it
// (referencing the file into the composer instead of opening it).
await fileRow.click({ position: { x: 8, y: 8 } })
await editorChunk
// In-place mode (editorExplorer default): the SAME tab switches to the
// file — its title is rewritten, no new tab appears. One "Files" tab
// remains: the second files window the sweep opened via the + menu.
await expect(
sidebar.locator('[title="Files"][draggable="true"]'),
'in-place mode rewrites the activated home tab instead of opening a new one',
).toHaveCount(1)
const pathInput = sidebar.locator('input[placeholder^="File path"]:visible')
await expect(pathInput, 'the files window header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`))
await page.waitForTimeout(1_500)
await assertNoCrash()
// The plugin's own console prefix must never appear in errors, and no
// unhandled rejection may escape the sweep.
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
expect(pluginErrors, 'plugin-prefixed or unhandled console errors during the sweep').toEqual([])
expect(pageErrors, 'pageerrors during the sweep').toEqual([])
// Final screenshot: the rendered panel with a session is the lane's proof.
await page.screenshot({ path: 'test-results/mount-final.png' })
})