From 85b09d888e9486132b214d8b7bad356677f49c18 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 28 Sep 2026 11:00:23 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E9=A1=B5=E9=9D=A2=E5=86=85?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E6=9B=BF=E6=8D=A2=E7=9A=84=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=BA=A7=E5=9B=9E=E5=BD=92=E9=97=A8=EF=BC=88=E7=9C=9F=E6=9C=BA?= =?UTF-8?q?=E7=BA=A2=E2=86=92=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 scratch profile 上用**已发布的 npm 0.22.0** 复现了线上那条报错链:页面内客户端 条目替换(宿主 HMR 的 500ms stat-poll 发现已安装 lib/client.js 的 rev 变化 → teardown → 重新 import)期间,旧激活的 teardown 触发 sync(),让 files 接管在 inactive ctx 上重建 —— 控制台依次出现 native register files error: ... cannot create effect on inactive context registerSlots ← registerFilesKind ← sync ← notify native register files error: ... tab type id "dsh-better-sidebar:files" is already registered register ← registerFilesKind ← sync ← notify 新用例 tests/e2e/native-reload.e2e.ts 把这条路径固化进挂载 lane(同一 lane 里 mount.e2e.ts 跑完后跑):utimesSync 已安装的 client bundle → 断言宿主元素被替换 (新激活)、无 native register … error、无诊断条、files 接管重新可用。 CLIENT_JS 从 e2e-mount.sh 已 export 的 DSH_HOME 推导,无需改脚本。 实测(同一台机器、同一份用例): - npm dsh-better-sidebar@0.22.0:3/3 红(每次都是上面那两条栈) - 本地打包的修复版:3/3 绿 AGENTS.md §2 补一条说明该门与触发方式。 --- AGENTS.md | 2 +- tests/e2e/native-reload.e2e.ts | 150 +++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/native-reload.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 47cf6d4..54b892e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`) -「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add `)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开本插件贡献的 tab 类型**以及宿主自己的终端条目**(必须恰好 1 条——多于 1 条即意味着插件又在遮蔽宿主),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。**`browser` 不在这个清单里,而是显式断言缺席**:DSH 0.1.7 的 `ui-sidebar-browser` 只在 desktop profile 挂载(`web-app` 的 patch 里 `disabled: ctx.get('profileContext')?.name !== 'desktop'`),lane 全走 web profile,钉住「没有」比默默放过更能暴露宿主立场的变化。 +「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add `)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开本插件贡献的 tab 类型**以及宿主自己的终端条目**(必须恰好 1 条——多于 1 条即意味着插件又在遮蔽宿主),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。**`tests/e2e/native-reload.e2e.ts` 是同一 lane 里的第二条门**:它 `utimesSync` 已安装的 `lib/client.js`(宿主 HMR 的 500ms stat-poll 以 `mtime/ctime/size` 的 sha1 作 rev)触发**页面内条目替换**,断言替换前后都无 `native register … error`、无诊断条、且 `files` 接管重新可用——这是唯一能在部署层复现「teardown 期间注册把 id 孤儿化」(见 §3.4 第 9 条)的用例,v0.22.0 上实测 3/3 红、修复后 3/3 绿。**`browser` 不在这个清单里,而是显式断言缺席**:DSH 0.1.7 的 `ui-sidebar-browser` 只在 desktop profile 挂载(`web-app` 的 patch 里 `disabled: ctx.get('profileContext')?.name !== 'desktop'`),lane 全走 web profile,钉住「没有」比默默放过更能暴露宿主立场的变化。 本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(`DSH_CMD` 未设时 `scripts/e2e-common.sh` 回退的 npx 钉版是 `DSH_NPX_SPEC`,默认 `@deepseek-ai/dsh@0.1.7-rc.1`——**不钉就会解析 `latest`,而 `latest` 仍是插件已不支持的 0.1.5 线**)。CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`(**pin 一律写精确版本号,不要写 `@alpha`**:rc.1 上线时 npm `alpha` 指的是 **0.1.7-alpha.2**,rc.1 走的是 `next`;`latest` 仍是 0.1.5-rc.3;peer 下限 `^0.1.7-rc.1`)。该步骤用 `NODE_OPTIONS=--max-old-space-size=4096`:钉版自身的传递依赖是浮动 `^` 范围,上游分阶段发布预发布版时(rc.2 于 2026-09-10 的 14:43–14:57 逐包上线)npm 会组出混合 peer 图,3062 条 ERESOLVE 后 OOM(exit 134);另一个失败模式是对未发布兄弟包 ETARGET,靠「钉一个已完整发布的版本」修掉。**不要加 `--legacy-peer-deps`**:它跳过的正是全局安装必须提供的 peer,`@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`(实测过一次,见 rc.2 计划 C 节)。`ci-windows` 的 `Test` 跑 `pnpm test:windows`(`--maxWorkers=1`)而非 `pnpm test`——多个 spec 真起进程(`smoke` / `install-powershell` / `git`),2 核 runner 上并行起 ConPTY / 冷启 `powershell.exe` 是超时与 worker 静默死亡的放大器;`vitest.config.ts` 的全局 `testTimeout: 15_000`(默认 5000 在 Windows 上对真起进程的用例太低,三个不同文件先后翻车)是配套的一半。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。 diff --git a/tests/e2e/native-reload.e2e.ts b/tests/e2e/native-reload.e2e.ts new file mode 100644 index 0000000..3c772d1 --- /dev/null +++ b/tests/e2e/native-reload.e2e.ts @@ -0,0 +1,150 @@ +/** + * In-page CLIENT ENTRY REPLACEMENT ("plugin reload") — the deployment-level + * guard for the `dsh-better-sidebar:files` orphaned-tab-type bug. + * + * Field symptom (issues #770/#771, reproduced on the official desktop shell): + * a client-side replacement of the plugin entry — a plugin-market update, a + * disable→enable in the Plugins page, an HMR rebundle — left the host's tab + * type id `dsh-better-sidebar:files` registered with nobody holding its + * disposer, so the plugin could never register it again: every later `sync()` + * reported `native register files error: … already registered` and the Files + * takeover rendered the host's "nothing here can view this kind of content" + * face until a page refresh. + * + * Root cause (fixed in v0.22.1): `sync()`'s drop loop treated the plugin's own + * `files` takeover like a service descriptor, so every notification tore it + * down and re-created it in the same pass. During the reload the re-creation + * ran on the dying plugin context: `tabs.register` (host context, still alive) + * took the id and `ctx.slots.inject` (plugin context) then threw + * `cannot create effect on inactive context`, losing the disposer. + * + * The trigger here is the same one the host's own HMR uses: the installed + * client bundle is stat-polled, and a changed revision (`mtime`/`ctime`/`size`) + * makes the host tear the entry's fiber down and re-import it IN PAGE — no + * full page navigation, which is what made the old failure permanent. + */ +import { mkdirSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect, type APIRequestContext } from '@playwright/test' +import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host' + +/** Workspace the sidebar renders against (created by this 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 takeover. */ +const SEEDED_FILE = 'hello.txt' + +/** + * The installed client bundle the host's HMR stat-poll watches. Derived from + * the profile the server was booted with (`DSH_HOME`), overridable for a + * hand-run lane. + */ +const CLIENT_JS = process.env.DSH_E2E_PLUGIN_CLIENT_JS + ?? (process.env.DSH_HOME === undefined + ? undefined + : join(process.env.DSH_HOME, 'profiles/web/node_modules/dsh-better-sidebar/lib/client.js')) + +/** The strip `fail()` pins to ; the plugin's registration failures land here. */ +const REGISTRATION_ERROR = /native register .* error/ + +let api: APIRequestContext + +test.beforeAll(async () => { + mkdirSync(WORKSPACE_PATH, { recursive: true }) + writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the reload lane\n') + 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('the native tab types survive an in-page client entry replacement', async ({ page }) => { + test.skip(CLIENT_JS === undefined, 'set DSH_HOME or DSH_E2E_PLUGIN_CLIENT_JS to locate the installed bundle') + const consoleErrors: string[] = [] + 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 }) + + // A keyless boot stacks onboarding takeovers over the shell (versioned + // welcome notice, then the provider-config dialog). Same bounded dismissal + // loop as the mount lane: a masked click is retried, an absent takeover is + // fine. + 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') + } + 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 again. + } + } + if (!dismissed) break + } + + // The session header (and with it the native right sidebar's controls) only + // exists once the session has a turn — the mount lane does the same. + await sendFirstMessage(page) + await page.locator('[data-sidebar-right-expand]').first().click() + const pane = page.locator('[data-sidebar-right-panel]') + await expect(page.locator('[data-sidebar-right-guide]')).toBeVisible({ timeout: 30_000 }) + // The takeover is ALIVE before the reload: its guide row opens the plugin's + // explorer and the seeded file is listed. + await page.locator('[data-sidebar-right-guide-entry="files"]').click() + await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 }) + + // ── Trigger: bump the installed bundle's revision ───────────────────── + // Tag the CURRENT host element so the replacement is observable: a disposal + // removes it (`host.remove()`), and the new activation appends a fresh one. + await page.evaluate(() => { + document.querySelector('[data-dsh-better-sidebar]')?.setAttribute('data-e2e-generation', 'before-reload') + }) + const now = new Date() + utimesSync(CLIENT_JS!, now, new Date(now.getTime() + 2_000)) + await expect( + page.locator('[data-dsh-better-sidebar][data-e2e-generation="before-reload"]'), + 'the host must replace the plugin entry in page (new activation)', + ).toHaveCount(0, { timeout: 60_000 }) + // Let the fresh activation finish its registrations. + await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 60_000 }) + await page.waitForTimeout(2_000) + + // ── Assertions: no registration failure, takeover usable ────────────── + const registrationErrors = consoleErrors.filter(message => REGISTRATION_ERROR.test(message)) + expect(registrationErrors, registrationErrors.join('\n')).toEqual([]) + await expect( + page.locator('body > div', { hasText: 'native register files error' }), + 'the failure strip must not be pinned', + ).toHaveCount(0) + // The Files takeover works again after the replacement: the guide row is + // offered and the explorer still lists the seeded file. + if (await page.locator('[data-sidebar-right-guide]').count() === 0) { + await page.locator('[data-dockkit-add-tab]').first().click() + } + const filesEntry = page.locator('[data-sidebar-right-guide-entry="files"]') + await expect(filesEntry, 'the Files guide row must be offered again').toHaveCount(1, { timeout: 30_000 }) + await filesEntry.click() + await expect(pane.locator('[data-sidebar-right-unavailable]')).toHaveCount(0) + await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 }) +})