diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c6295..1fe97a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,14 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + +# Cancel a superseded PR run (it is on a stale commit); never cancel a +# push run — it is already producing the post-merge signal. The mount job +# boots a real DSH + Chromium, so the savings matter there most. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci: @@ -41,3 +49,57 @@ jobs: # shipped lib/types. Runs AFTER build (it reads the built output). - name: Check consumer type surface run: pnpm check:consumer-types + + # Pack the plugin as an npm tarball, mount it into a REAL DSH instance + # through the official `dsh plugin --profile web add` channel, and render + # the DSH web UI headlessly (Playwright Chromium) to prove the plugin + # mounts without crashing the shell. Drives `pnpm test:mount`, which boots + # a keyless `dsh web` against a scratch profile and sweeps every built-in + # sidebar tab. No API key needed — the whole lane is deterministic. + plugin-mount: + runs-on: ubuntu-latest + timeout-minutes: 30 + # CI runs must never report to the production telemetry endpoint baked + # into the DSH composition (same guard DSH's own CI uses). + env: + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + with: + version: 11 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Pin the DSH CLI the lane mounts into; the profile's bundle stack + # (dsh-base / dsh-web-app) resolves from this installation. Keep in + # step with the plugin's @deepseek-ai/* peer range when bumping. + - name: Install dsh CLI (pinned) + run: npm install -g @deepseek-ai/dsh@0.1.0-rc.6 + + # lib/ is gitignored; the tarball is produced from this build. + - name: Build and pack the npm tarball + run: pnpm build && pnpm pack + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Mount + headless-render smoke + run: pnpm test:mount + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b38fa09..4cbe7d3 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,8 @@ registry/ # stray npm cache scratch dir .npm-cache-tmp/ + +# Playwright headless-render lane (tests/e2e) artifacts +playwright-report/ +test-results/ + diff --git a/AGENTS.md b/AGENTS.md index fe8474f..1a1fabe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,16 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context - **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles//`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。 - 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 +### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`) + +仓库的 CI 有一条「npm 打包 → 真实挂载 → 无头渲染」门禁(`.github/workflows/ci.yml` 的 `plugin-mount` job),证明**打包产物**在真实 DSH 上挂载后无头渲染不会 crash: + +1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。 +2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。 +3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,并通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Explorer 打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`)——缺失的内置 tab 或 chunk 都会使门禁变红。 + +本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。 + --- ## 1. 服务定位 diff --git a/package.json b/package.json index 040f7da..a8c8b31 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "watch": "tsdown --watch", "prepare": "tsdown", "test": "vitest run", + "test:mount": "bash scripts/e2e-mount.sh", "check:consumer-types": "bash scripts/check-consumer-types.sh" }, "license": "MIT", @@ -137,6 +138,7 @@ "xterm": "^5.3.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6", @@ -152,11 +154,11 @@ "@deepseek-ai/dsh-session": "^0.1.0-rc.6", "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@playwright/test": "^1.62.1", "@types/node": "^24.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.1", "@types/ws": "^8.5.10", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7", "jsdom": "^29.1.1", "lightningcss": "^1.32.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7073e4f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,39 @@ +/** + * Playwright config for the headless-render mount lane (tests/e2e). + * + * This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a + * real `dsh web` instance (with the npm-packed plugin mounted through the + * official `dsh plugin add` channel) and injects the base URL through + * `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium + * and prove the plugin mounts without crashing the shell. + * + * Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include + * never collects them; vitest.config.ts excludes the directory as well. + */ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + // The lane's specs are named *.e2e.ts (so vitest's default include never + // collects them); tell Playwright to pick exactly that shape. + testMatch: '**/*.e2e.ts', + // A deterministic gate: no retries, one worker, one browser. The whole lane + // is a crash probe against one server instance, so serial execution keeps + // the assertions meaningful (a crash would trip the very next check). + retries: 0, + workers: 1, + fullyParallel: false, + timeout: 120_000, + reporter: [ + ['list'], + // Local debugging artifact; CI uploads the report dir on failure. + ['html', { open: 'never' }], + ], + use: { + browserName: 'chromium', + headless: true, + viewport: { width: 1440, height: 900 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b55b8..cf62710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: ^0.1.0-rc.6 version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0) + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -974,6 +977,11 @@ packages: '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1473,6 +1481,11 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1789,6 +1802,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -3184,6 +3207,10 @@ snapshots: '@oxc-project/types@0.144.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -3542,6 +3569,9 @@ snapshots: fflate@0.8.3: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4051,6 +4081,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.26: dependencies: nanoid: 3.3.18 diff --git a/scripts/e2e-mount.sh b/scripts/e2e-mount.sh new file mode 100755 index 0000000..ba12c7d --- /dev/null +++ b/scripts/e2e-mount.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# ============================================================================= +# dsh-better-sidebar 挂载冒烟编排(CI + 本地): +# +# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch +# profile(`dsh plugin --profile web add file:`,触发 +# dsh.profile.bundles 协调,与用户安装路径一致); +# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口); +# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与 +# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。 +# +# 用法: +# bash scripts/e2e-mount.sh [--grep ] +# +# 环境变量(均可省略): +# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包 +# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack) +# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL) +# DSH_HOME_BASE 覆盖 scratch 根目录(默认 mktemp -d) +# KEEP_HOME 非空时保留 scratch home(调试用) +# +# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。 +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +DSH_CMD="${DSH_CMD:-dsh}" +PORT="${PORT:-0}" +TARBALL="${TARBALL:-}" +GREP_FILTER="" +if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi + +say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; } + +command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)" +command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)" + +# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh) +if ! command -v "$DSH_CMD" >/dev/null 2>&1; then + if command -v npx >/dev/null 2>&1; then + say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh" + DSH_CMD="npx -y --package @deepseek-ai/dsh dsh" + else + die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定" + fi +fi + +# tarball 解析 +if [ -z "$TARBALL" ]; then + TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)" +fi +[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack" +TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")" +say "tarball: $TARBALL" + +# scratch home(每次全新,绝不触碰真实 ~/.dsh) +SCRATCH="${DSH_HOME_BASE:-$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)}" +export DSH_HOME="$SCRATCH/home" +WORKSPACE_DIR="$SCRATCH/workspace" +LOG_DIR="$SCRATCH" +WEB_LOG="$LOG_DIR/web.log" +mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR" +say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})" + +SERVER_PID="" +cleanup() { + local code=$? + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + if [ -z "${KEEP_HOME:-}" ]; then + rm -rf "$SCRATCH" + else + warn "KEEP_HOME 已设置,保留 $SCRATCH" + fi + exit "$code" +} +trap cleanup EXIT + +# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写 +# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11 +# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh) +PROFILE_DIR="$DSH_HOME/profiles/web" +cat > "$PROFILE_DIR/package.json" < "$PROFILE_DIR/cordis.patch.yml" +cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF' +packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false + +allowBuilds: + node-pty: true + protobufjs: true + +minimumReleaseAgeExclude: + - dsh-better-sidebar +EOF + +# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径) +say "执行 dsh plugin --profile web add file:$TARBALL ..." +$DSH_CMD plugin --profile web add "file:$TARBALL" + +# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar) +if ! node -e ' + const fs = require("fs"); + const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const bundles = p.dsh?.profile?.bundles ?? []; + process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1); +' "$PROFILE_DIR/package.json"; then + warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册" + cat "$PROFILE_DIR/package.json" + exit 1 +fi +say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar" + +# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起) +say "启动 dsh web(port=${PORT})..." +$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 & +SERVER_PID=$! + +URL="" +for _ in $(seq 1 120); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "=== dsh web 提前退出,日志尾部 ===" >&2 + tail -30 "$WEB_LOG" >&2 || true + exit 1 + fi + if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then + break + fi + sleep 1 +done +[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; } +say "dsh web 就绪:${URL}(pid ${SERVER_PID})" + +# 步骤 5:运行无头渲染 lane +say "运行 Playwright 无头渲染 lane..." +DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \ + pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"} + +say "通过:插件挂载到真实 DSH 后无头渲染未崩溃" diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts new file mode 100644 index 0000000..70f211e --- /dev/null +++ b/tests/e2e/mount.e2e.ts @@ -0,0 +1,199 @@ +/** + * 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. sweeps every built-in tab (Explorer / Source Control / Tasks / + * Terminal / Browser) — including the lazily-fetched terminal chunk — + * and then opens a seeded file through the Explorer to force the + * lazily-fetched editor chunk (client-editor.js) to load as well. + * + * 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 Explorer to force the + * lazily-packed editor chunk (client-editor.js) to load. */ +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 = ['Explorer', '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 { + 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. + 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 }) + + // Crash-marker assertions shared by every step. + const assertNoCrash = async (): Promise => { + 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 tab is hidden from the + menu — its CodeMirror chunk + // (client-editor.js) only loads when a file is opened. Exercise that path + // explicitly: reopen Explorer, open the seeded file, and require the chunk + // round-trip, so a missing/corrupt editor chunk fails the lane. + await newTabButton.click() + const explorerItem = page.getByRole('menuitem', { name: 'Explorer' }).first() + await expect(explorerItem, 'Explorer must be re-openable for the editor-chunk probe').toHaveCount(1) + await explorerItem.click() + const editorChunk = page.waitForResponse( + (response) => response.url().includes('/sidebar/bundle/editor.js'), + { timeout: 30_000 }, + ) + const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]`) + await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the Explorer tree`).toHaveCount(1, { timeout: 30_000 }) + await fileRow.click() + await editorChunk + 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' }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index ee4cf96..7f1c62e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,5 +19,18 @@ export default defineConfig({ inline: [/@deepseek-ai\/dsh-client-ui-primitives/], }, }, + // The Playwright headless-render lane lives in tests/e2e (specs named + // *.e2e.ts). Keep vitest from ever collecting it, both by naming (the + // default include only matches *.test.* / *.spec.*) and by an explicit + // exclude. NOTE: `exclude` REPLACES vitest's defaults, so the standard + // node_modules/dist/etc. excludes must be restated here. + exclude: [ + 'tests/e2e/**', + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', + ], }, })