test(e2e): 页面内条目替换的部署级回归门(真机红→绿)
在 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 补一条说明该门与触发方式。
This commit is contained in:
@@ -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 <tarball>`)并启动真实 `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 <tarball>`)并启动真实 `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 整体替换默认值)。
|
||||
|
||||
|
||||
@@ -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 <body>; 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 })
|
||||
})
|
||||
Reference in New Issue
Block a user