Ver Fonte

test(web): cover live bundle toggles through the plugin Remote

Yichen Jiang há 1 semana atrás
pai
commit
ecf8bfcccb

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
-2026-09-04-plugin-management-in-web-settings.md: 51833d98ebda06d735de994e8ed551379065c12f
-2026-09-04-plugin-management-in-web-settings.zh.md: 7b5ac292d49b53046c277ea642c173a3a574d23d
+2026-09-04-plugin-management-in-web-settings.md: 29aff42ae6fafad2e1d7a20362676722ea6228df
+2026-09-04-plugin-management-in-web-settings.zh.md: 0f8de3fc9106e55cc651e769e76d0bb2a542b033

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md

@@ -22,7 +22,7 @@ The Host could manage a profile's plugins — install, enable, disable, retry, c
 
 **The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('<name>')` in the workspace against the names the gateway's own source reserves.
 
-**The web e2e scaffold can mount a profile runtime.** `launchWebScaffold({ profileRuntime })` links fixture packages into the scaffold profile and mounts `ProfileRuntime` over it with `patchReload: 'startup'`, so the manager has a profile to manage while the booted tree never recomposes under a scenario.
+**The web e2e scaffold supports live and startup-only profiles.** `launchWebScaffold({ profileRuntime })` links fixture packages into an isolated profile. Its `patchReload` choice defaults to `startup`; live scenarios compose bundle and user layers through the profile runtime. The browser enable/disable regression checks both the manifest and actual Loader rows without restarting the scaffold.
 
 Package discovery and runtime health follow [native entry diagnostics and static declarations](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md). Every declared module remains addable without a preflight execution verdict; unknown packages remain installed. Active rows retain visible update failures, and failed overrides identify their target entries without transferring ownership.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md

@@ -22,7 +22,7 @@ Status: implemented
 
 **安装动词是 `add`。** 客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,并在页面加载时——所有单测都通过之后——拒绝同名的挂载方法。宿主的方法与 CLI 一样叫 `plugins/add`,`packages/api/remotes/tests/remote-method-names.host.spec.ts` 用网关源码自己保留的名字检查工作区里每一个 `@Remote('<name>')`。
 
-**web e2e 脚手架可以挂 profile runtime。** `launchWebScaffold({ profileRuntime })` 把 fixture 包链接进脚手架 profile,并以 `patchReload: 'startup'` 在其上挂载 `ProfileRuntime`,于是管理器有 profile 可管,而启动好的树在场景之下绝不重新组合
+**Web e2e 脚手架支持实时和仅启动时生效的 profile。** `launchWebScaffold({ profileRuntime })` 将 fixture 包链接到隔离的 profile。`patchReload` 默认选择 `startup`;实时场景通过 profile runtime 组合 bundle 与用户层。浏览器启停回归同时检查清单和实际 Loader 行,期间不重启脚手架
 
 包发现与运行健康遵循[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)。每个声明模块均可添加,不依赖执行预检结论;未知包保持已安装。活跃行仍显示更新失败,失败的覆盖会标识目标条目而不转移归属。
 

+ 11 - 0
apps/web/tests/expected/plugin-manager/live-enabled.expected.md

@@ -0,0 +1,11 @@
+- heading "插件管理" [level=1]
+- paragraph: 管理已安装的插件。
+- button "刷新"
+- button "添加插件"
+- heading "插件包" [level=3]
+- text: 1 个
+- list:
+  - listitem:
+    - button "查看 示例组合包": 示例组合包
+    - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
+    - switch "启用 示例组合包" [checked]

+ 1 - 2
apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml

@@ -1,5 +1,4 @@
-# Web e2e fixture bundle: one inert row, so enabling it changes the profile
-# manifest without composing anything the scaffold tree would notice.
+# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes.
 - insert:
     - id: fixture-row
       name: '@fixture/bundle'

+ 48 - 1
apps/web/tests/plugin-manager.e2e.ts

@@ -20,6 +20,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
 const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
 const DETAIL_EXPECTED = join(SNAPSHOT_DIR, 'preset-detail.expected.md')
+const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md')
 const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
 const MODE = webSnapshotMode()
 
@@ -227,6 +228,52 @@ describe('web e2e: plugin manager', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'preset-detail.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'preset-detail.expected.md', 'live-enabled.expected.md'])
   })
 })
+
+
+describe('web e2e: live plugin management', () => {
+  it('applies bundle enable and disable through the Remote without a restart', async () => {
+    const scaffold = await launchWebScaffold({
+      profileRuntime: { patchReload: 'live', packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
+    })
+    let browser: Browser | undefined
+    try {
+      browser = await chromium.launch()
+      const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+      const tripwire = watchConsole(page)
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-live'))
+      await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+      await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+      const panel = page.locator('[data-plugin-panel]')
+      const toggle = panel.getByRole('switch', { name: '启用 示例组合包' })
+      await toggle.waitFor({ timeout: 20_000 })
+      const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
+      const bundles = async () => {
+        const text = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8')
+        return (JSON.parse(text) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles
+      }
+      expect(mounted()).toBeUndefined()
+      await toggle.click()
+      await expect.poll(() => mounted()?.fiber?.state, { timeout: 10_000 }).toBe(2)
+      await expect.poll(bundles).toEqual(['@fixture/bundle'])
+      await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
+      expect(await panel.getByText('需重启', { exact: true }).count()).toBe(0)
+      expect(await panel.getByText(/以下更改会在下次启动生效/).count()).toBe(0)
+      const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
+
+      await toggle.click()
+      await expect.poll(mounted, { timeout: 10_000 }).toBeUndefined()
+      await expect.poll(bundles).toEqual([])
+      await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
+      expect(await panel.getByText('需重启', { exact: true }).count()).toBe(0)
+      expect(await panel.getByText(/以下更改会在下次启动生效/).count()).toBe(0)
+      expect(tripwire.pageErrors).toEqual([])
+    } finally {
+      await browser?.close()
+      await scaffold.close()
+    }
+  }, 60_000)
+})

+ 18 - 27
apps/web/tests/scaffold.ts

@@ -33,8 +33,7 @@ import { expect } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import Group from '@deepseek-ai/cordis-plugin-group'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import {
   captureExpectedWorkspaceSnapshot,
   captureWorkspaceSnapshot,
@@ -57,7 +56,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import {
-  auditStartupEntries, claimLayerIds,
+  auditStartupEntries, composeProfileStack, mountRootInclude, rootIncludeEntry,
   composeEntries,
   healProfilesModuleFallback,
   loadOverlayPatches,
@@ -308,11 +307,11 @@ export interface LaunchOptions {
    * manager has a profile to manage. Each package directory is linked into
    * the profile as an installed dependency (`file:` in its manifest, a
    * symlink under its `node_modules`); `enabled` lists a bundle in
-   * `dsh.profile.bundles`. The profile applies layer changes at its next
-   * start, so an enable or disable is reported as pending and the booted tree
-   * never recomposes under the scenario.
+   * `dsh.profile.bundles`. `patchReload` selects live recomposition or
+   * changes applied at the next start; it defaults to `startup`.
    */
   profileRuntime?: {
+    patchReload?: Profile['patchReload']
     packages: { dir: string; enabled?: boolean }[]
   }
   /**
@@ -670,6 +669,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   try {
     process.chdir(workspaceCwd)
     const profileDir = join(harnessHome, 'profiles', 'scaffold')
+    const profilePatchReload = options.profileRuntime?.patchReload ?? 'startup'
     const extraLayers: Profile['layers'] = await Promise.all((options.extraInstallAnchors ?? []).map(async (anchor) => {
       const manifest = JSON.parse(await readFile(anchor, 'utf8')) as { name?: unknown }
       if (typeof manifest.name !== 'string' || manifest.name === '') {
@@ -698,7 +698,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         layers: extraLayers,
         patchPath: join(profileDir, 'cordis.patch.yml'),
         patches: [],
-        patchReload: 'startup',
+        patchReload: profilePatchReload,
       },
     })
     await mkdir(profileDir, { recursive: true })
@@ -716,7 +716,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       writeProfileManifest(profileDir, {
         name: 'dsh-profile-scaffold',
         dependencies,
-        dsh: { profile: { bundles, patchReload: 'startup' } },
+        dsh: { profile: { bundles, patchReload: profilePatchReload } },
       })
     }
     const rootConfig = join(profileDir, 'cordis.yml')
@@ -735,36 +735,27 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       },
     })
     await ctx.plugin(Loader)
-    ctx.loader.builtins.include = Include
-    // `cordis:group` beside it, exactly as `boot()` registers it: a group row is
-    // how a preset gives one `isolate` realm to a provider and its consumers,
-    // and a preset resolving package names from its own directory cannot reach
-    // `@deepseek-ai/cordis-plugin-group` by name.
-    ctx.loader.builtins.group = Group
-    let rootIncludeId: string | undefined = undefined
+    let rootPatches = patches
     if (options.profileRuntime !== undefined) {
       // Provenance is available before any configuration entry activates.
-      // The scaffold profile applies layer changes at startup.
       const readProfile = (): Profile => loadProfile('dsh', 'scaffold', INSTALL_ANCHOR, harnessHome)
       const profile = readProfile()
-      // The scaffold's own patches, with the id ownership the runtime answers `originOf` from.
-      const compose = (): ComposedStack => ({
-        patches, layers: [{ label: 'scaffold', patches }], owners: claimLayerIds(profile.layers).owners, conflicts: [], skippedBundles: [],
-        userDisabledRowIds: new Set<string>(),
-      })
+      const compose = (current: Profile): ComposedStack => composeProfileStack('web e2e scaffold', current.layers, [
+        { label: 'scaffold', patches },
+        { label: current.patchPath, patches: current.patches },
+      ])
+      const stack = compose(profile)
+      rootPatches = stack.patches
       await ctx.plugin(ProfileRuntime, {
         profile,
-        stack: compose(),
+        stack,
         installAnchor: INSTALL_ANCHOR,
         loadProfile: readProfile,
         compose,
-        rootEntry: () => [...ctx.loader.entries()].find(entry => entry.id === rootIncludeId),
+        rootEntry: () => rootIncludeEntry(ctx),
       })
     }
-    rootIncludeId = await ctx.loader.create({
-      name: 'cordis:include',
-      config: { path: pathToFileURL(rootConfig).href, patches },
-    })
+    await mountRootInclude(ctx, rootConfig, rootPatches)
     await ctx.loader.await()
     await auditStartupEntries(ctx, 'web e2e scaffold')
     if (options.welcomeNoticePending !== true) {