diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index ceccd5ead5..a41dd3bd98 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -21,8 +21,8 @@ en: 0a4240d88cca240a zh: 8f5f1527caf571cd /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end: - en: 15fb56befd328339 - zh: 0ffb10ada7e52805 + en: dd05aa1ee608c414 + zh: 2863ced0086e3122 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation: en: 37e2fa1fe5cb2b95 zh: 80e513db87709cb4 @@ -33,8 +33,8 @@ en: 94ca0d09e6fd1e3a zh: d5949a2bdec795ba /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences: - en: 16e1f2467c559315 - zh: a714ec6ff2722cf1 + en: 624efdd136029771 + zh: 7d60207147e631fe /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered: en: b8f036f50cff7cd8 zh: 6d623c4a1f4314b1 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 28d718586b..15c0a39295 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -67,8 +67,8 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser. 2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry. 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting. -4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor. -5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. +4. `settled` = every entry created + `loader.await()` quiescent + the boot audit. The audit lists each import-failed, FAILED, or PENDING fiber with its activation error or missing services. It exists because cordis inject waits have no timeout. An inactive entry on the browser required list rejects startup; other inactive entries produce one console warning ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). +5. The framework-free loading page projects real fiber states via `internal/status`. After the audit accepts the tree, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. ### Live graph reconciliation @@ -104,7 +104,7 @@ The current package inventory and build forms live in the [client shell layering One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references. -Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. +Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the boot audit; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 1bf50a15ba..95333823eb 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -67,8 +67,8 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。 2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。 -4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。 -5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 +4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 启动审计。审计列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其激活错误或缺失的服务。它存在的理由:cordis 的 inject 等待没有超时。浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。 +5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。审计接受插件树后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 ### 动态图对账 @@ -104,7 +104,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。 -接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 +接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在启动审计时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index f7082b3940..954230cc2a 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml @@ -9,8 +9,8 @@ en: 4548e7fbdbee5b5a zh: 0078762ea92fa180 /agent-note-consumer-owned-startup-strictness/decision: - en: 1f11e04162e19ad1 - zh: d102e300eba9a2ea + en: 62e93742af5bed66 + zh: 3944d685b6503a2c /agent-note-consumer-owned-startup-strictness/alternatives-considered: en: 1b67e062213184cd zh: 91cc8a015de2bfe4 @@ -18,5 +18,5 @@ en: 283dec1b336c540d zh: 2ed358079072cfff /agent-note-consumer-owned-startup-strictness/testing: - en: c38221069ce98fd3 - zh: 9a2f91f539eba4d4 + en: cee14c22297e5471 + zh: a68748cc36f4a574 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index cf17823e72..be4e748975 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -20,6 +20,8 @@ The audit runs only during initial application boot. Later config HMR remains be This policy governs [Web host boot](../../../../packages/boot/app-boot/README.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit. +The browser boot audit applies the same policy to the client plugin tree with its own global list in [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts): the module system, Session control, the renderer, the application frame, the Session view, and its sidebar. Browser entries are named by package, so the list holds package names; a name absent from the boot graph has no effect, and neither list is derived from the other. The list is the minimum fatal set: an entry that a listed entry waits on blocks startup too. Feature plugins, including credential masking, stay optional. A failed entry reports its activation error. Optional failures appear in one console warning, and in Settings → Plugins → Plugin list only after a later graph synchronization reconciles them. Renaming a listed package requires updating the list and its tests. + ## Alternatives considered - **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group. @@ -36,6 +38,8 @@ The compact terminal report keeps the failing plugins visible; a separate file r App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. +Browser audit unit tests (`boot-client.client.spec.ts`, `boot.client.spec.ts`) cover required and optional import, creation, activation, and pending failures, carrier presentation, and required names absent from the boot graph. Test carriers are stricter than the product: `TestClient` requires every roster entry to activate unless a case passes `allowInactiveEntries`, and the browser e2e lanes treat a `web boot: warning` as a failure with no opt-out. + The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../../../../packages/boot/app-boot/tests/user-patches.spec.ts): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index c8bda5cb20..4287b4aad2 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -20,6 +20,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles 该策略适用于 [Web host 启动](../../../../packages/boot/app-boot/README.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。 +浏览器启动审计以同一策略审计客户端插件树,使用 [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts) 中独立的全局名单:模块系统、Session 控制、渲染器、应用框架、Session 视图及其侧栏。浏览器 entry 以包名命名,因此名单写包名;启动图中不存在的名字不产生影响,两份名单互不推导。名单是最小致命集合:已列出 entry 所等待的 entry 同样会阻止启动。功能插件(包括凭据遮挡)保持可选。失败 entry 报告其激活错误。可选失败出现在一条控制台警告中;只有后续图同步调和之后,才会出现在「设置 → 插件 → 插件列表」。重命名名单中的包需要同步更新名单及其测试。 + ## 考虑过的替代方案 - **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。 @@ -36,6 +38,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 +浏览器审计单元测试(`boot-client.client.spec.ts`、`boot.client.spec.ts`)覆盖必需与可选 entry 的导入、创建、激活与 pending 失败、carrier 呈现,以及启动图中不存在的必需名称。测试载体比产品更严格:除非用例传入 `allowInactiveEntries`,`TestClient` 要求每个 roster entry 都激活;浏览器 e2e 把 `web boot: warning` 视为失败,且不提供放宽。 + [Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。 diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index e314a707ba..068cc2c07e 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md /apps-web-browser-e2e: - en: 2deef0df72a6465c - zh: b24989ffb2d8586a + en: 88e00180b920a42e + zh: 8ddba0f4bbb4f1f6 /apps-web-browser-e2e/completion-observations: en: 8c139966983959cc zh: 54f09f9b7ac84cda diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 969cf3a965..be2467e2d1 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -16,6 +16,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m Same-day message goldens use the shared `WEB_FIXTURE_TIME`: seeded transcripts pass it as `createdAt` and pin the browser calendar with `pinBrowserClock`; scenarios that submit live messages also use `pinHostClock` and restore it after teardown. These clocks advance with real elapsed time while keeping native timers intact, so midnight does not change a scenario's date labels. +Every scenario requires the whole client composition to activate. The product boot only warns about inactive entries outside its required list, but the `watchConsole` tripwire, the [real-host smoke](smoke-real.e2e.ts), and the [assembled jsdom boot](assembled-boot.ts) treat a `web boot: warning` as a failure. No scenario option relaxes this: an inactive plugin in a product composition is a defect to fix. + ## Completion observations State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 29234f33c6..317725a2dc 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -16,6 +16,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps 当天消息的 golden 使用共享的 `WEB_FIXTURE_TIME`:预置会话将它传为 `createdAt`,并通过 `pinBrowserClock` 固定浏览器日期;提交实时消息的场景还使用 `pinHostClock`,在清理后恢复。固定后的时钟随真实耗时前进,保留原生定时器,因此跨午夜不会改变场景的日期标签。 +每个场景都要求整个客户端组合全部激活。产品启动只对必需名单外的未激活 entry 告警,但 `watchConsole` 绊线、[真实 Host 冒烟](smoke-real.e2e.ts)与[组装 jsdom 启动](assembled-boot.ts)把 `web boot: warning` 视为失败。没有任何场景选项放宽这一点:产品组合中出现未激活插件就是需要修复的缺陷。 + ## 完成状态观察 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 42f006222d..88dc7e9f94 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -196,13 +196,16 @@ class EventSourceStub { const win = window as FixtureWindow let unmount: (() => Promise) | undefined let mountedRemote: RemoteMock | undefined +/** Boot audit warnings of the current test; the product boot tolerates inactive optional entries, this lane does not. */ +let bootWarnings: string[] = [] /** * Register the per-test jsdom setup and teardown the assembled boot needs: * English pinned before boot so role/text locators stay deterministic across * localized component migrations (the newEnglishPage e2e convention), the * observers, font events, and frame callbacks jsdom lacks, and a full reset of the document, - * the boot globals, and the injected plugin styles afterwards. + * the boot globals, and the injected plugin styles afterwards. A boot audit warning about an + * inactive entry fails the test at teardown: every assembled composition must activate completely. */ export function installAssembledBootEnv(): void { // jsdom implements no scroll geometry: the trigger menu reveals its @@ -219,7 +222,15 @@ export function installAssembledBootEnv(): void { }) } let fontsDescriptor: PropertyDescriptor | undefined + let restoreWarn: (() => void) | undefined beforeEach(() => { + bootWarnings = [] + const warn = console.warn.bind(console) + const spy = vi.spyOn(console, 'warn').mockImplementation((message?: unknown, ...rest: unknown[]) => { + if (typeof message === 'string' && message.startsWith('web boot: warning')) bootWarnings.push(message) + warn(message, ...rest) + }) + restoreWarn = () => { spy.mockRestore() } fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() }) localStorage.clear() @@ -267,6 +278,9 @@ export function installAssembledBootEnv(): void { vi.unstubAllGlobals() if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') else Object.defineProperty(document, 'fonts', fontsDescriptor) + restoreWarn?.() + restoreWarn = undefined + if (bootWarnings.length > 0) failures.push(new Error(`assembled boot: every entry must activate\n${bootWarnings.join('\n')}`)) if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed') }) } diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 92f307c095..a42e31ddd5 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1761,10 +1761,12 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P /** * Console tripwires: reconnect/gap-repair self-healing or a pageerror must - * fail the scenario, not mask a dead wire behind eventual consistency. + * fail the scenario, not mask a dead wire behind eventual consistency. A boot + * audit warning fails it too: the product boot tolerates inactive optional + * entries, but every e2e composition must activate completely. */ export interface WebConsoleTripwire { - /** Console warnings matching reconnect/gap-repair/discontinuity copy. */ + /** Console warnings matching reconnect/gap-repair/discontinuity copy or the boot audit's inactive-entry warning. */ warnings: string[] /** Uncaught page errors. */ pageErrors: string[] @@ -1780,7 +1782,7 @@ export function watchConsole(page: Page): WebConsoleTripwire { const pageErrors: string[] = [] page.on('console', (message) => { const text = message.text() - if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text) + if (/connection lost|gap repair|discontinuous|web boot: warning/i.test(text)) warnings.push(text) }) page.on('pageerror', (error) => { pageErrors.push(String(error)) }) return { warnings, pageErrors } diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index d763d6847a..e2992e2a63 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -706,6 +706,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke let browser: Browser let page: Page const pageErrors: string[] = [] + // The product boot only warns about inactive optional entries; this lane requires the whole composition. + const bootWarnings: string[] = [] beforeAll(async () => { requireDist() @@ -742,6 +744,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke browser = await chromium.launch() page = await newEnglishPage(browser) page.on('pageerror', e => pageErrors.push(String(e))) + page.on('console', (message) => { + if (message.text().startsWith('web boot: warning')) bootWarnings.push(message.text()) + }) await page.goto(baseUrl, { waitUntil: 'load' }) }, 120_000) @@ -760,6 +765,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke onTestFailed(() => saveFailureShot(page, 'w5-cold-start')) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await page.locator('text=Failed to load plugins').count()).toBe(0) + expect(bootWarnings).toEqual([]) const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns) expect(template.split(' ').length).toBe(3) await screen(page, '01-cold-start') diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 593dcc3494..f201ba2f7e 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -9,8 +9,8 @@ en: c0454bca818f86ee zh: 427582150fd3b976 /web-client-architecture/browser-boot: - en: 5ac7d3aed7bf273a - zh: e34869c8ad64052d + en: cd68c46a3f365235 + zh: 9ac2f7c65cabb144 /web-client-architecture/remote-communication: en: 426b09164210b77a zh: ce576099ee668984 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index bb68cd64a1..93f2fbdf1d 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -23,7 +23,7 @@ The dependency direction is Host state → Remote transport → Client model → The Host writes the composed `WebBootGraph` to `window.__DSH_BOOT__` and installs the browser module-loader facade before parser-preloaded scripts execute. The module system is a lazy CommonJS table: loading a bundle registers its factory, while materializing an entry runs the factory with synchronous `require` over platform modules and declared dynamic dependencies. -The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state, `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. +The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state and the boot audit accepts it (an inactive entry on the browser required list rejects startup; other inactive entries produce one console warning), `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. ## Remote communication diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index a3d62f738f..3c0c0da5bb 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -23,7 +23,7 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。 Host 把组合后的 `WebBootGraph` 写入 `window.__DSH_BOOT__`,并在 parser-preloaded script 执行前安装浏览器 module-loader facade。模块系统是一张 lazy CommonJS 表:加载 bundle 只注册 factory;materialize entry 时才以同步 `require` 运行 factory,并解析 platform module 和已声明的动态依赖。 -Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态后,`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 +Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态且启动审计通过后(浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告),`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 ## Remote 通信 diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 8f9a9d597f..86ee490193 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -54,8 +54,8 @@ en: 253d9f265cd83ed0 zh: 9d0f8df84d9d3ac9 /deepseek-ai-dsh-client-modules/understand-the-implementation/entry-ownership: - en: df2994f9ff8872c9 - zh: cd776526ed2b51e6 + en: 608a25c52159629f + zh: c5ef242899cb32c1 /deepseek-ai-dsh-client-modules/understand-the-implementation/source-map: en: d8b5407a0279a3f9 zh: 27af3437fad78672 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index a27f4271fd..3cf748d9a0 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -92,7 +92,7 @@ The host contributes structured index rows that inject, into ``: the `wind ### Entry ownership -`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available. +`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. As the Host Loader group does, a boot entry whose creation rejects, such as a module that exports no plugin, is logged and stays inactive for the boot audit instead of aborting startup. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available. ### Source map diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index ab6fe609b3..9016041c36 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -93,7 +93,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册 ### 条目所有权 -`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。 +`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。与 Host Loader 分组一样,启动时创建失败的条目(例如模块未导出插件)会记录日志并保持未激活,交由启动审计处理,而不会中止启动。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。 ### 源码索引 diff --git a/packages/client/modules/src/client/entries.ts b/packages/client/modules/src/client/entries.ts index e33d0055f8..5da84399ff 100644 --- a/packages/client/modules/src/client/entries.ts +++ b/packages/client/modules/src/client/entries.ts @@ -77,6 +77,8 @@ export class ClientEntries { /** * Create the initial roster and retain its entry identities for subsequent reconciliation. + * As the Host Loader group does, a row whose creation rejects (for example a module that exports no + * plugin) is logged and stays an inactive entry, so one row cannot abort startup before the boot audit. * @param loader - Page Loader, already configured with the module system. * @param manifest - Initial roster audited by the boot caller. * @returns after initial entries and their activation settle; boot owns its activation audit. @@ -92,7 +94,7 @@ export class ClientEntries { }, 'client-modules: entry reconciliation') return this.enqueue(async () => { await Promise.all(this.desired.plugins.map(async ({ id }) => { - await this.create(loader, id) + await this.create(loader, id).catch((error: unknown) => { loader.ctx.logger.error(error) }) })) await loader.await() for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev) diff --git a/packages/client/modules/tests/entries.client.spec.ts b/packages/client/modules/tests/entries.client.spec.ts index dc908bea33..0f56f99b10 100644 --- a/packages/client/modules/tests/entries.client.spec.ts +++ b/packages/client/modules/tests/entries.client.spec.ts @@ -347,6 +347,22 @@ it('retains a rejected Loader entry for retry instead of creating an orphan sibl expect(entry.fiber?.state).toBe(2) }) +it('logs a row whose Loader creation rejects at startup and leaves it inactive for the boot audit', async () => { + let broken = true + const b = await bench(graph(row('a'), row('b')), { a: () => broken ? { default: 'invalid' } : { apply() {} }, b: () => ({ apply() {} }) }, false) + const error = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {}) + await b.modules.entries.start(b.ctx.loader, b.modules.manifest) + const entries = new Map([...b.ctx.loader.entries()].map(entry => [entry.options.name, entry])) + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('invalid plugin')) + expect(entries.get('a')?.fiber).toBeUndefined() + expect(entries.get('b')?.fiber?.state).toBe(2) + broken = false + await b.modules.entries.retry() + expect([...b.ctx.loader.entries()]).toHaveLength(2) + expect(entries.get('a')?.fiber?.state).toBe(2) +}) + it('reports Loader import failures and recovers the same entry after the importer recovers', async () => { const b = await bench(graph(), { a: () => ({ apply() {} }) }) let broken = true diff --git a/packages/client/ui-renderer/README.i18n.yaml b/packages/client/ui-renderer/README.i18n.yaml index 5022127e14..df00ec41b1 100644 --- a/packages/client/ui-renderer/README.i18n.yaml +++ b/packages/client/ui-renderer/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-renderer/use-this-package: - en: 39d5f36f7cb7d680 - zh: a61f0aa8b339e06f + en: b83a441098d5b7fd + zh: 57cd24edbc2e6602 /deepseek-ai-dsh-client-ui-renderer/use-this-package/what-mounting-does: en: 1d5350799ce072d0 zh: 942e543fdab51818 @@ -45,8 +45,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-renderer/known-limitations-and-deferred-work: - en: 5ebaf02736035a44 - zh: 3c04f28da7ec6c40 + en: 14bccc68da30d0f3 + zh: 6b6b306556cdd8a4 /deepseek-ai-dsh-client-ui-renderer/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-renderer/README.md b/packages/client/ui-renderer/README.md index e6699e8d98..13a759a182 100644 --- a/packages/client/ui-renderer/README.md +++ b/packages/client/ui-renderer/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -This package is infrastructure: the web shell and the boot kernel are its only direct consumers. A composition needs it whenever it wants a React-rendered GUI — `dsh-client-web` loads the roster, waits for every entry to activate, then calls `ctx.uiRenderer.mount(container)`. +This package is infrastructure: the web shell and the boot kernel are its only direct consumers. A composition needs it whenever it wants a React-rendered GUI — `dsh-client-web` loads the roster, waits for it to settle and for the boot audit to accept it, then calls `ctx.uiRenderer.mount(container)`. ### What mounting does @@ -91,7 +91,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the application frame appears and how far per-region readiness goes; they are current package constraints. -- **The first application frame waits for every client entry** — the boot kernel hands over the mount point only after the loader roster settles; per-region readiness remains deferred. +- **The first application frame waits for the whole client roster** — the boot kernel hands over the mount point only after the loader roster settles and the boot audit accepts it; per-region readiness remains deferred. - **Slot rendering has no Suspense integration or per-entry lazy loading** — the complete plugin roster settles before the renderer mounts the root. diff --git a/packages/client/ui-renderer/README.zh.md b/packages/client/ui-renderer/README.zh.md index bac1dc9064..cdf8de1820 100644 --- a/packages/client/ui-renderer/README.zh.md +++ b/packages/client/ui-renderer/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -本包属于基础设施:Web 外壳与启动内核是它仅有的直接消费方。只要组合需要 React 渲染的 GUI,就需要它——`dsh-client-web` 加载名册,等待每个 entry 激活,然后调用 `ctx.uiRenderer.mount(container)`。 +本包属于基础设施:Web 外壳与启动内核是它仅有的直接消费方。只要组合需要 React 渲染的 GUI,就需要它——`dsh-client-web` 加载名册,等待名册稳定且启动审计通过,然后调用 `ctx.uiRenderer.mount(container)`。 ### 挂载做什么 @@ -91,7 +91,7 @@ React、React DOM、Cordis、ui-slots 与 ui-primitives 通过 Web 外壳的静 这些限制说明应用首帧何时出现、按区域就绪能走多远;它们是当前包约束。 -- **应用首帧会等待全部客户端 entry**:启动内核只在 loader 名册稳定后交出挂载点;按区域就绪仍属暂缓事项。 +- **应用首帧会等待整个客户端名册**:启动内核只在 loader 名册稳定且启动审计通过后交出挂载点;按区域就绪仍属暂缓事项。 - **slot 渲染没有 Suspense 集成或逐 entry 惰性加载**:完整插件名册稳定后,渲染器才挂载根节点。 diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 5d8df516ef..e9cb6261e0 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/README.i18n.yaml @@ -9,8 +9,8 @@ en: 8423614617b7bbf1 zh: c99647136c800173 /deepseek-ai-dsh-client-web/summary: - en: 646bd0a246789400 - zh: d875dc0d37f46338 + en: 620b47ee6556f578 + zh: 4d64125df458a6bd /deepseek-ai-dsh-client-web/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -21,8 +21,8 @@ en: f3b01aa878764e5c zh: f02eab65d441bfe0 /deepseek-ai-dsh-client-web/use-this-package/the-boot-page: - en: 1757ef1631612ff5 - zh: d2d3fd0313908c12 + en: 0e77a7d3fc52f5a7 + zh: 9da54cac5839906e /deepseek-ai-dsh-client-web/use-this-package/the-shared-module-table: en: 33b256e108a7bd6a zh: 3b2b1368721803b7 @@ -33,14 +33,14 @@ en: 9556ca6438410146 zh: 8a60572cb4caa04a /deepseek-ai-dsh-client-web/understand-the-implementation/design-concept: - en: e18ec3ae51dcdc3f - zh: e454da2038979aec + en: a8e8f2e466eff0a1 + zh: e0216136dcf36934 /deepseek-ai-dsh-client-web/understand-the-implementation/two-stage-boot: - en: fb3cab79c1505627 - zh: 875897afe593aeb3 + en: ad7ebdc7aa77c15d + zh: 664520e0fd3ca7a1 /deepseek-ai-dsh-client-web/understand-the-implementation/boot-page-mechanics: - en: 83f9195138da9a82 - zh: 135d7da735fb870a + en: a651d65bede943a5 + zh: 481c25e6892a4abc /deepseek-ai-dsh-client-web/understand-the-implementation/source-map: en: 36046f6bc4347fa5 zh: a6a8c55ba77fd77b @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-web/known-limitations-and-deferred-work: - en: f498ba05d4ea96d5 - zh: c3e57dd8ca9b5bda + en: 718bd1dee0fc8f2d + zh: 0053746647650ddd /deepseek-ai-dsh-client-web/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 314845997b..a24e7f5127 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-web` boots the web GUI: it loads the client module system from the Host-provided boot graph, then activates every client plugin before the application mounts, so the full UI appears only when every plugin is up. A framework-free boot page reports per-entry status, so a failing bundle or plugin stays visible instead of a blank screen. It also defines the shared module table (`PLATFORM_MODULES`) that every dynamic bundle resolves its externals against. The model never sees this package. +`dsh-client-web` boots the web GUI: it loads the client module system from the Host-provided boot graph, then activates the client plugins before the application mounts. The UI mounts once every required plugin and every plugin a required one waits on is up; any other plugin that fails is reported in the console and left out. A framework-free boot page keeps a required-plugin failure visible instead of a blank screen. It also defines the shared module table (`PLATFORM_MODULES`) that every dynamic bundle resolves its externals against. The model never sees this package. ## Table of Contents @@ -39,7 +39,7 @@ Boot runs in two stages: the module stage adopts the parser-loaded bootstrap bat ### The boot page -The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import failure, or state) instead of a blank page. The console contains the original import error. +The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate. The spinner and its animation phase persist until the full UI replaces the boot page. When a required plugin fails import or activation, the page names every inactive plugin with the reason (missing service, import failure, or activation error) and marks the required ones, instead of a blank page. An optional plugin's failure appears in one console warning and does not stop the application. The console contains the original import error. ### The shared module table @@ -66,17 +66,17 @@ This section explains how the boot kernel is built; observable behavior is cover ### Design concept -The kernel owns exactly three things: the module system, the Cordis Loader, and the boot page. The Host owns the graph, batch preload, and loader facade, so `AppWebEntry` never knows the bootstrap package id or parses the wire format. The dynamic UI renderer receives the mount point only after every client entry activates. +The kernel owns exactly three things: the module system, the Cordis Loader, and the boot page. The Host owns the graph, batch preload, and loader facade, so `AppWebEntry` never knows the bootstrap package id or parses the wire format. The dynamic UI renderer receives the mount point only after the activation audit accepts the client tree. ### Two-stage boot -`run()` calls the Host-installed `window.__ModuleLoader__.create({ boot, staticModules, ...seams })`; the facade returns the constructed module system and parsed manifest after adopting the parser-loaded bootstrap batch. The module stage prefetches the `immediately` tier through the one shared application-batch URL. The plugin stage mounts the Loader, assigns `loader.internal = modules`, creates every graph entry uniformly, awaits quiescence, then audits activation: any entry that failed import, stayed pending on a missing service, or landed in another non-active state throws one aggregated error naming every failing entry. +`run()` calls the Host-installed `window.__ModuleLoader__.create({ boot, staticModules, ...seams })`; the facade returns the constructed module system and parsed manifest after adopting the parser-loaded bootstrap batch. The module stage prefetches the `immediately` tier through the one shared application-batch URL. The plugin stage mounts the Loader, assigns `loader.internal = modules`, creates every graph entry uniformly, awaits quiescence, then audits activation with the same policy as [Host startup strictness](../../../.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md): an entry that failed import, stayed pending on a missing service, or landed in another non-active state is inactive. When an inactive entry is on the global required list in [`src/boot-client.ts`](src/boot-client.ts) (the module system, Session control, renderer, application frame, Session view, and sidebar), the audit throws one aggregated error naming every inactive entry and marking the required ones; otherwise it writes one console warning and the application mounts. A required name absent from the graph has no effect. An entry that a required entry waits on leaves the required entry pending, so its failure also rejects startup. An entry the Loader could not create, because its module failed to import or exports no plugin, has no fiber and is reported as an import failure, as the Host audit reports `failed to import`; the original error is in the console log. ### Boot page mechanics -The boot page is plain DOM with local CSS whose fallback fonts and colors match the theme tokens that arrive during loading. `internal/status` events drive one spinner node and per-entry labels; hydration preserves the node and animation phase through the application commit, and `fail()` renders the thrown reason. React mounting, slot rendering, and assembly live in `ui-renderer`; `ui-layout` owns the assembled browser-title projection. +The boot page is plain DOM with local CSS whose fallback fonts and colors match the theme tokens that arrive during loading. `internal/status` events drive one spinner node; a failed entry is not drawn before the audit, because it may be optional. Hydration preserves the node and animation phase through the application commit, and `fail()` renders the thrown reason. React mounting, slot rendering, and assembly live in `ui-renderer`; `ui-layout` owns the assembled browser-title projection. -The boot kernel delegates manifest entry creation to Client Modules so live graph synchronization owns the same entry identities after startup. The initial activation audit remains strict; later page-local failures appear in Settings → Plugins → Plugin list. +The boot kernel delegates manifest entry creation to Client Modules so live graph synchronization owns the same entry identities after startup. The initial activation audit rejects only required entries; later page-local failures appear in Settings → Plugins → Plugin list. ### Source map @@ -126,7 +126,7 @@ None; this package neither assembles nor sends a provider request. These limits define what the boot kernel does not support. They are current package constraints, not a task backlog. -- **The application waits for the full roster** — one failed entry keeps the framework-free boot page visible with a per-entry report; partial UI availability is not supported. +- **Optional boot failures start console-only** — an optional plugin that fails during the initial boot, including one whose creation the Loader rejects, appears in the console warning; Settings → Plugins → Plugin list lists it only after a later graph synchronization reconciles it. ### Dev Note diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index ef31351620..dc491887e9 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-web` 启动 web GUI:它先从 Host 提供的启动图加载客户端模块系统,再在应用挂载前激活每一个客户端插件,因此只有当所有插件都就绪时完整 UI 才会出现。无框架启动页会逐 entry 报告状态,因此失败的 bundle 或插件保持可见,而不是白屏。它还定义共享模块表(`PLATFORM_MODULES`),每个动态 bundle 都依据它解析 external。模型永远看不到本包。 +`dsh-client-web` 启动 web GUI:它先从 Host 提供的启动图加载客户端模块系统,再在应用挂载前激活客户端插件。所有必需插件及其所等待的插件就绪后 UI 即挂载;其余失败的插件在控制台报告并被略过。无框架启动页让必需插件的失败保持可见,而不是白屏。它还定义共享模块表(`PLATFORM_MODULES`),每个动态 bundle 都依据它解析 external。模型永远看不到本包。 ## 目录 @@ -39,7 +39,7 @@ Web 和 Desktop 的数学公式渲染要求支持 WOFF2。[Web 构建](../../../ ### 启动页 -启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入失败或状态),而不是白屏。控制台包含原始导入错误。 +启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。必需插件导入或激活失败时,页面按名称列出每个未激活插件及原因(缺失服务、导入失败或激活错误),并标出必需项,而不是白屏。可选插件的失败只出现在一条控制台警告中,不会阻止应用启动。控制台包含原始导入错误。 ### 共享模块表 @@ -66,17 +66,17 @@ Web 和 Desktop 的数学公式渲染要求支持 WOFF2。[Web 构建](../../../ ### 设计理念 -内核恰好拥有三样东西:模块系统、Cordis Loader 与启动页。Graph、批次 preload 与 loader facade 归 Host 所有,因此 `AppWebEntry` 永不感知 bootstrap 包 id,也不解析协议格式(wire format)。动态 UI 渲染器只在每个客户端 entry 激活后收到挂载点。 +内核恰好拥有三样东西:模块系统、Cordis Loader 与启动页。Graph、批次 preload 与 loader facade 归 Host 所有,因此 `AppWebEntry` 永不感知 bootstrap 包 id,也不解析协议格式(wire format)。动态 UI 渲染器只在激活审计接受客户端插件树后收到挂载点。 ### 两阶段启动 -`run()` 调用 Host 安装的 `window.__ModuleLoader__.create({ boot, staticModules, ...seams })`;facade 接纳 parser 已加载的 bootstrap 批次后返回构造好的模块系统与已解析 manifest(元数据清单)。模块阶段通过一个共享的 application 批次 URL 预取 `immediately` 层级。插件阶段挂载 Loader、把 `loader.internal` 赋为 `modules`、统一创建全部图 entry、等待完全停稳,然后审计激活:任何导入失败、因缺失服务而 pending,或落入其他非 active 状态的 entry,都会抛出一个聚合错误,点名每个失败 entry。 +`run()` 调用 Host 安装的 `window.__ModuleLoader__.create({ boot, staticModules, ...seams })`;facade 接纳 parser 已加载的 bootstrap 批次后返回构造好的模块系统与已解析 manifest(元数据清单)。模块阶段通过一个共享的 application 批次 URL 预取 `immediately` 层级。插件阶段挂载 Loader、把 `loader.internal` 赋为 `modules`、统一创建全部图 entry、等待完全停稳,然后按与 [Host 启动严格性](../../../.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md)相同的策略审计激活:导入失败、因缺失服务而 pending,或落入其他非 active 状态的 entry 视为未激活。未激活 entry 在 [`src/boot-client.ts`](src/boot-client.ts) 的全局必需名单(模块系统、Session 控制、渲染器、应用框架、Session 视图与侧栏)中时,审计抛出一个聚合错误,点名每个未激活 entry 并标出必需项;否则写一条控制台警告,应用照常挂载。图中不存在的必需名称不产生影响。必需 entry 所等待的 entry 失败时,必需 entry 保持 pending,因此同样会拒绝启动。Loader 未能创建的 entry(模块导入失败或未导出插件)没有 fiber,按导入失败报告,与 Host 审计报告 `failed to import` 相同;原始错误在控制台日志中。 ### 启动页机制 -启动页是原生 DOM 加本地 CSS,其回退字体与颜色匹配加载期间到达的主题 token。`internal/status` 事件驱动一个 spinner 节点与逐 entry 标签;hydrate 会保留该节点与动画相位直到应用提交,`fail()` 渲染抛出的原因。React 挂载、slot 渲染与应用组装位于 `ui-renderer`;`ui-layout` 拥有组装后的浏览器标题投影。 +启动页是原生 DOM 加本地 CSS,其回退字体与颜色匹配加载期间到达的主题 token。`internal/status` 事件驱动一个 spinner 节点;失败的 entry 在审计前不绘制,因为它可能是可选的。hydrate 会保留该节点与动画相位直到应用提交,`fail()` 渲染抛出的原因。React 挂载、slot 渲染与应用组装位于 `ui-renderer`;`ui-layout` 拥有组装后的浏览器标题投影。 -启动内核把清单条目创建交给 Client Modules,使启动后的动态图同步继续持有相同的条目身份。初始激活审计仍然严格;后续页面本地失败显示在「设置 → 插件 → 插件列表」。 +启动内核把清单条目创建交给 Client Modules,使启动后的动态图同步继续持有相同的条目身份。初始激活审计只拒绝必需 entry;后续页面本地失败显示在「设置 → 插件 → 插件列表」。 ### 源码地图 @@ -126,7 +126,7 @@ Web 和 Desktop 的数学公式渲染要求支持 WOFF2。[Web 构建](../../../ 这些限制说明启动内核不支持什么。它们是当前包约束,不是任务积压。 -- **应用会等待全部 entry 就绪**——只要一个 entry 失败,无框架启动页就会保留并逐项报告;不支持部分 UI 可用。 +- **可选插件的启动失败起初只在控制台可见**——初始启动时失败的可选插件(包括 Loader 拒绝创建的插件)出现在控制台警告中;只有后续图同步调和之后,「设置 → 插件 → 插件列表」才会列出它。 ### 开发备注 diff --git a/packages/client/web/src/boot-client.ts b/packages/client/web/src/boot-client.ts index bbc8e215e2..9ad95f3770 100644 --- a/packages/client/web/src/boot-client.ts +++ b/packages/client/web/src/boot-client.ts @@ -4,7 +4,7 @@ * activation. `AppWebEntry` and the whole-client test carrier both call it. * @module @deepseek-ai/dsh-client-web/src/boot-client */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client' import { STATE_LABELS } from './loader-status.ts' @@ -12,6 +12,26 @@ import { STATE_LABELS } from './loader-status.ts' /** Entry state label as the boot page renders it. */ export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed' +/** + * Entry names whose presence defines a usable Web client. + * + * The list is global rather than page or profile metadata. A name absent from + * the boot manifest does not affect startup; a listed entry that is present + * must activate. The list covers the module system, Session control, the + * renderer and application frame, and the Session view with its sidebar. It is + * the minimum fatal set: an entry that a listed entry waits on leaves it + * inactive, so that entry's failure also rejects startup. + */ +const requiredClientEntryNames = new Set([ + '@deepseek-ai/dsh-client-modules', + '@deepseek-ai/dsh-api-session-controller', + '@deepseek-ai/dsh-client-ui-renderer', + '@deepseek-ai/dsh-client-ui-layout', + '@deepseek-ai/dsh-client-ui-session', + '@deepseek-ai/dsh-client-ui-chat', + '@deepseek-ai/dsh-client-ui-sidebar', +]) + /** Inputs of {@link bootClient}. */ export interface ClientBootOptions { /** Fresh root Context that will own the plugin tree. */ @@ -29,9 +49,10 @@ export interface ClientBootOptions { * `loader.create({ name })` per manifest row, `loader.await()`, then * {@link assertEntriesActive}. A row whose module cannot be imported is marked * failed; the Loader logs its import error, the module system records it, and - * the audit rejects startup with that error text per entry. + * the audit reports that error text per entry. * @param options - context, module system, manifest, optional progress sink. - * @returns resolves after every entry is active; rejects with the audit report otherwise. + * @returns resolves after the audit accepts the tree, including after optional-entry warnings; + * rejects with the audit report when a required entry is inactive. */ export async function bootClient(options: ClientBootOptions): Promise { const { ctx, manifest, onEntryState } = options @@ -53,37 +74,76 @@ export async function bootClient(options: ClientBootOptions): Promise { } await loader.await() - assertEntriesActive(ctx, options.modules) + await assertEntriesActive(ctx, options.modules) } /** - * Reject entries that failed import/apply or still wait on missing services. + * Audit entries that failed import/apply or still wait on missing services. + * An inactive entry named in the required list rejects startup; other + * inactive entries produce one warning and leave active siblings running. + * A failed entry reports its recorded activation error; reading it awaits + * only failed fibers, which have settled by the time the Loader has. * @param ctx - root Context carrying the Loader. * @param modules - the module system whose recorded import failures name why an entry - * has no fiber; a row with no record points at the console. - * @throws {Error} listing every non-active entry with its reason. + * has no fiber; a row with no record points at the console. An entry without a fiber + * failed to import or was rejected at creation (its module exports no plugin); like the + * Host audit's `failed to import`, both are reported as an import failure. + * @param warn - sink for the optional-entry warning. + * @returns resolves after any optional-entry warning. + * @throws {Error} rejects when a required entry is inactive, listing every non-active entry with its + * reason and marking the required ones; no warning is written in that case. */ -export function assertEntriesActive(ctx: Context, modules: Pick): void { - const failures: string[] = [] +export async function assertEntriesActive( + ctx: Context, + modules: Pick, + warn: (message: string) => void = (message) => { console.warn(message) }, +): Promise { + const failures: { name: string; reason: string }[] = [] for (const entry of ctx.loader.entries()) { const name = entry.options.name if (entry.fiber === undefined) { const importError = modules.importError(name) - failures.push(importError === undefined - ? `${name}: import failed (see console for the import error)` - : `${name}: import failed: ${importError.message}`) + failures.push({ + name, + reason: importError === undefined + ? 'import failed (see console for the import error)' + : `import failed: ${importError.message}`, + }) continue } const state = STATE_LABELS[entry.fiber.state] if (state === 'active') continue if (state === 'pending') { const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) - failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + failures.push({ name, reason: `pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})` }) + } else if (state === 'failed') { + failures.push({ name, reason: await activationFailure(entry.fiber) }) } else { - failures.push(`${name}: ${state}`) + failures.push({ name, reason: state }) } } - if (failures.length > 0) { - throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + if (failures.length === 0) return + const isRequired = (name: string): boolean => requiredClientEntryNames.has(name) + const required = failures.filter(({ name }) => isRequired(name)).length + if (required > 0) { + const lines = failures.map(({ name, reason }) => `${name}${isRequired(name) ? ' (required)' : ''}: ${reason}`) + throw new Error(`web boot: ${String(required)} required ${entryNoun(required)} did not activate\n${lines.join('\n')}`) } + const lines = failures.map(({ name, reason }) => `${name}: ${reason}`) + warn(`web boot: warning: ${String(failures.length)} ${entryNoun(failures.length)} did not activate\n${lines.join('\n')}`) +} + +/** Singular or plural noun for an entry count in audit reports. */ +function entryNoun(count: number): string { + return count === 1 ? 'entry' : 'entries' +} + +/** A failed fiber's recorded activation error; Cordis exposes it only through `await()`, which rethrows it. */ +async function activationFailure(fiber: Pick): Promise { + try { + await fiber.await() + } catch (error) { + return `failed: ${String(error)}` + } + return 'failed' } diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 6515ed160f..fe49e19d3e 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -2,7 +2,7 @@ * Web boot kernel. It owns only the module system, Cordis loader, and a * framework-free boot page; plugin composition and the renderer handoff are * `bootClient` and `mountClient`. The dynamic UI renderer receives the mount - * point after every client entry activates. + * point after the activation audit accepts the client tree. * @module @deepseek-ai/dsh-client-web/src/boot */ import { Context } from '@deepseek-ai/cordis' @@ -41,8 +41,10 @@ export class AppWebEntry { } /** - * Load and activate every client entry, then hand the mount point to the - * UI renderer. Plugin failures remain visible on the boot page. + * Load and activate every client entry, audit activation, then hand the + * mount point to the UI renderer. A required-entry failure remains visible + * on the boot page; optional-entry failures are reported to the console and + * the application mounts without them. * @param onFailure - Optional carrier-owned fatal presentation; keeps the boot page visible. * @returns Resolves after application mount or failure reporting. */ @@ -86,8 +88,9 @@ export class AppWebEntry { ctx, modules: this.modules, manifest: this.manifest, + // A failed entry may be optional; only the audit decides whether the boot page reports a failure. onEntryState: (name, state) => { - if (onFailure === undefined || state !== 'failed') this.page.setState(name, state) + if (state !== 'failed') this.page.setState(name, state) }, }) // The shell owns the one watcher that keeps Electron's window drag rects in diff --git a/packages/client/web/tests/boot-client.client.spec.ts b/packages/client/web/tests/boot-client.client.spec.ts index 17503e320a..e362670b1a 100644 --- a/packages/client/web/tests/boot-client.client.spec.ts +++ b/packages/client/web/tests/boot-client.client.spec.ts @@ -9,6 +9,8 @@ import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/bo import { FIBER_STATE } from '../src/loader-status.ts' const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules' +const REQUIRED_ID = '@deepseek-ai/dsh-client-ui-chat' +const OTHER_REQUIRED_ID = '@deepseek-ai/dsh-client-ui-layout' function graphOf(ids: readonly string[]): WebBootGraph { const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' })) @@ -66,20 +68,20 @@ describe('bootClient', () => { await ctx.fiber.dispose() }) - it('reports a row waiting on a service the roster never provides', async () => { - const graph = graphOf(['orphan']) - const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } }) + it('rejects a required row waiting on a service the roster never provides', async () => { + const graph = graphOf([REQUIRED_ID]) + const { modules } = modulesOf(graph, { [REQUIRED_ID]: { inject: ['nothing'], apply: () => {} } }) const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow( - 'orphan: pending (waiting for service: nothing)', + `${REQUIRED_ID} (required): pending (waiting for service: nothing)`, ) - await ctx.fiber.dispose() }) - it('reports and logs an import failure for a row that is neither seeded nor a graph row', async () => { + it('reports and logs an import failure for a required row that is neither seeded nor a graph row', async () => { const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } }) - const manifest = parseBootManifest(graphOf(['ghost'])) + const manifest = parseBootManifest(graphOf([REQUIRED_ID])) const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) @@ -87,16 +89,67 @@ describe('bootClient', () => { const sink = stateSink() await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow( - 'web boot: 1 entry did not activate\nghost: import failed (see console for the import error)', + `web boot: 1 required entry did not activate\n${REQUIRED_ID} (required): import failed (see console for the import error)`, ) - expect(sink.states.get('ghost')).toEqual(['loading', 'failed']) + expect(sink.states.get(REQUIRED_ID)).toEqual(['loading', 'failed']) expect(error).toHaveBeenCalledOnce() expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('client-modules: cannot resolve')) }) + + it('resolves past an optional row that cannot be imported and warns once', async () => { + const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } }) + const manifest = parseBootManifest(graphOf(['ghost'])) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore(); warn.mockRestore() }) + + await bootClient({ ctx, modules, manifest }) + + expect(warn).toHaveBeenCalledExactlyOnceWith( + 'web boot: warning: 1 entry did not activate\nghost: import failed (see console for the import error)', + ) + }) + + it('resolves past an optional row whose module exports no plugin and warns once', async () => { + const { modules } = modulesOf(graphOf(['invalid']), { invalid: { default: 'invalid' } }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore(); warn.mockRestore() }) + + await bootClient({ ctx, modules, manifest: modules.manifest }) + + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('invalid plugin')) + // The module imported, but an entry without a fiber is reported as an import failure, as the Host audit reports + // `failed to import`; the creation error itself is in the log asserted above. + expect(warn).toHaveBeenCalledExactlyOnceWith( + 'web boot: warning: 1 entry did not activate\ninvalid: import failed (see console for the import error)', + ) + }) + + it('rejects a required row whose module exports no plugin and names it', async () => { + const { modules } = modulesOf(graphOf([REQUIRED_ID]), { [REQUIRED_ID]: { default: 'invalid' } }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + + // Reported as an import failure like the Host audit's `failed to import`; the creation error goes to the log. + await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow( + `web boot: 1 required entry did not activate\n${REQUIRED_ID} (required): import failed (see console for the import error)`, + ) + }) }) describe('assertEntriesActive', () => { - interface FakeEntry { name: string; fiber?: { state: number; inject: Record } } + interface FakeEntry { name: string; fiber?: { state: number; inject: Record; await?: () => Promise } } + + /** A settled failed fiber whose `await()` rethrows its recorded activation error, as Cordis does. */ + const failedFiber = (error: Error) => ({ state: FIBER_STATE.FAILED, inject: {}, await: () => Promise.reject(error) }) /** Loader-shaped double: entries with scripted fiber states, services by name. */ function auditCtx(entries: readonly FakeEntry[], services: Record = {}): Context { @@ -112,36 +165,67 @@ describe('assertEntriesActive', () => { const silent = { importError: () => undefined } - it('passes when every entry is active', () => { - expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }]), silent) }).not.toThrow() + it('passes without a warning when every entry is active', async () => { + const warn = vi.fn<(message: string) => void>() + await assertEntriesActive(auditCtx([{ name: REQUIRED_ID, fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }]), silent, warn) + expect(warn).not.toHaveBeenCalled() }) - it('names import failures, missing services, and other non-active states', () => { + it('ignores required names absent from the boot graph', async () => { + const warn = vi.fn<(message: string) => void>() + await assertEntriesActive(auditCtx([{ name: 'subset-only', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }]), silent, warn) + expect(warn).not.toHaveBeenCalled() + }) + + it('warns about import failures, missing services, and activation errors of optional entries', async () => { const ctx = auditCtx([ { name: 'lost' }, { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } }, { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } }, - { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } }, + { name: 'broken', fiber: failedFiber(new TypeError('apply boom')) }, ], { present: {} }) + const warn = vi.fn<(message: string) => void>() - expect(() => { assertEntriesActive(ctx, silent) }).toThrow([ - 'web boot: 4 entries did not activate', + await assertEntriesActive(ctx, silent, warn) + + expect(warn).toHaveBeenCalledExactlyOnceWith([ + 'web boot: warning: 4 entries did not activate', 'lost: import failed (see console for the import error)', 'waiting: pending (waiting for services: a, b)', 'opaque: pending (waiting for services: unknown)', - 'broken: failed', + 'broken: failed: TypeError: apply boom', ].join('\n')) }) - it('uses the singular form for one failing entry', () => { - expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }]), silent) }).toThrow('web boot: 1 entry did not activate\n') + it('rejects inactive required entries and lists every inactive entry without a separate warning', async () => { + const ctx = auditCtx([ + { name: REQUIRED_ID }, + { name: 'optional', fiber: failedFiber(new RangeError('out of range')) }, + { name: OTHER_REQUIRED_ID, fiber: { state: FIBER_STATE.PENDING, inject: { theme: null } } }, + ]) + const warn = vi.fn<(message: string) => void>() + + await expect(assertEntriesActive(ctx, silent, warn)).rejects.toThrow([ + 'web boot: 2 required entries did not activate', + `${REQUIRED_ID} (required): import failed (see console for the import error)`, + 'optional: failed: RangeError: out of range', + `${OTHER_REQUIRED_ID} (required): pending (waiting for service: theme)`, + ].join('\n')) + expect(warn).not.toHaveBeenCalled() }) - it('names the recorded import error of a fiberless entry when the module system is supplied', () => { + it('uses the singular form for one required entry', async () => { + await expect(assertEntriesActive(auditCtx([{ name: REQUIRED_ID }]), silent)) + .rejects.toThrow('web boot: 1 required entry did not activate\n') + }) + + it('names the recorded import error of a fiberless entry when the module system is supplied', async () => { const recorded = new Map([['lost', new Error('client-modules: could not load "lost": plugins/??lost/client.js&rev=0: bundle script failed to load')]]) const modules = { importError: (id: string) => recorded.get(id) } - expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }, { name: 'quiet' }]), modules) }).toThrow([ - 'web boot: 2 entries did not activate', + const warn = vi.fn<(message: string) => void>() + await assertEntriesActive(auditCtx([{ name: 'lost' }, { name: 'quiet' }]), modules, warn) + expect(warn).toHaveBeenCalledExactlyOnceWith([ + 'web boot: warning: 2 entries did not activate', 'lost: import failed: client-modules: could not load "lost": plugins/??lost/client.js&rev=0: bundle script failed to load', 'quiet: import failed (see console for the import error)', ].join('\n')) diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts index 9ac945073b..e3b1461fec 100644 --- a/packages/client/web/tests/boot.client.spec.ts +++ b/packages/client/web/tests/boot.client.spec.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AppWebEntry } from '../src/boot.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' +const REQUIRED_ID = '@deepseek-ai/dsh-client-ui-chat' const PROVIDER_CLIENT_ID = 'provider/client' const RUNTIME_CLIENT_ID = 'runtime/client' const win = globalThis as DshWindow @@ -70,19 +71,19 @@ describe('bootstrap failure rendering', () => { await entry.dispose() }) - it.each([false, true])('routes failed plugin activation with carrier presentation=%s', async (carrier) => { + it.each([false, true])('routes failed required plugin activation with carrier presentation=%s', async (carrier) => { vi.spyOn(console, 'error').mockImplementation(() => {}) const container = document.createElement('div') document.body.append(container) const target = installFacade() win.__DSH_BOOT__ = { rev: 'graph', - entries: [{ id: 'broken', url: '/broken.js', rev: '1' }], - batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: ['broken'] }], + entries: [{ id: REQUIRED_ID, url: '/broken.js', rev: '1' }], + batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: [REQUIRED_ID] }], } const entry = new AppWebEntry(container, { loadBundle: async () => { - target.load({ id: 'broken', factory: () => ({ apply() { throw new Error('plugin activation failed') } }) }) + target.load({ id: REQUIRED_ID, factory: () => ({ apply() { throw new Error('plugin activation failed') } }) }) }, }) const report = vi.fn<(reason: unknown) => void>() @@ -90,19 +91,56 @@ describe('bootstrap failure rendering', () => { await entry.run(carrier ? report : undefined) if (carrier) { expect(report).toHaveBeenCalledOnce() - expect(String(report.mock.calls[0]![0])).toContain('broken') + expect(String(report.mock.calls[0]![0])).toContain(`${REQUIRED_ID} (required): failed: Error: plugin activation failed`) expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull() expect(container.textContent).not.toContain('Failed to load plugins') } else { expect(report).not.toHaveBeenCalled() expect(container.textContent).toContain('Failed to load plugins') - expect(container.textContent).toContain('broken') + expect(container.textContent).toContain(`${REQUIRED_ID} (required): failed: Error: plugin activation failed`) } } finally { await entry.dispose() } }) + it.each([false, true])('mounts past a failed optional plugin without a failure report with carrier presentation=%s', async (carrier) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const container = document.createElement('div') + document.body.append(container) + const target = installFacade() + const entries: WebBootEntry[] = [ + { id: 'broken', url: '/broken.js', rev: '1' }, + { id: 'renderer', url: '/renderer.js', rev: '1' }, + ] + win.__DSH_BOOT__ = { + rev: 'graph', + entries, + batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: entries.map(row => row.id) }], + } + const mounted = vi.fn<(element: HTMLElement) => () => void>(() => () => {}) + const entry = new AppWebEntry(container, { + loadBundle: async () => { + target.load({ id: 'broken', factory: () => ({ apply() { throw new Error('plugin activation failed') } }) }) + target.load({ + id: 'renderer', + factory: () => ({ apply: (ctx: Context) => { ctx.reflect.provide('uiRenderer', { mount: mounted }) } }), + }) + }, + }) + const report = vi.fn<(reason: unknown) => void>() + try { + await entry.run(carrier ? report : undefined) + expect(mounted).toHaveBeenCalledExactlyOnceWith(container) + expect(report).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledExactlyOnceWith('web boot: warning: 1 entry did not activate\nbroken: failed: Error: plugin activation failed') + expect(container.textContent).not.toContain('Failed to load plugins') + } finally { + await entry.dispose() + } + }) + it('renders a missing bootstrap facade', async () => { await expectBootFailure( () => { delete win.__ModuleLoader__ }, diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 3da93e2162..87f0b8aca1 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -27,8 +27,8 @@ en: cbcfc9a331783c98 zh: 1cc2f8e21a3d0dd4 /deepseek-ai-dsh-client-test-runtime/use-this-package/whole-client-tier: - en: 1442fc215c8128e8 - zh: 8439861b08e46ea2 + en: a0d72c709f6fad43 + zh: 4fadf2e8eb566237 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: en: 4dc5a5be6d89f43c zh: df2d67460aa863d9 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 6164a11dbb..7de9577383 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -89,7 +89,7 @@ test('registers into the sidebar', async ({ remote, start }) => { }) ``` -`createClientTest` uses native Vitest fixtures: each test gets a fresh `mock` loaded with `remoteDefaultResponses`, a `remote` proxy equal to `mock.remote`, and a `start()` that boots only after the test configures its responses. Repeated starts share one promise; await it to observe startup errors. Fixture teardown waits for startup, disposes the client even after a failed assertion, checks missing responses, and refuses a saved `start` after the test. Use `TestClient.start` directly for independently owned clients. These fixtures isolate their own state, not page globals such as `location`. +`createClientTest` uses native Vitest fixtures: each test gets a fresh `mock` loaded with `remoteDefaultResponses`, a `remote` proxy equal to `mock.remote`, and a `start()` that boots only after the test configures its responses. Repeated starts share one promise; await it to observe startup errors. Unlike the product boot, which only warns about inactive entries outside its required list, every roster entry must activate by default; pass `allowInactiveEntries: true` to `createClientTest` or `TestClient.start` only for a case that expects an optional plugin to fail. Fixture teardown waits for startup, disposes the client even after a failed assertion, checks missing responses, and refuses a saved `start` after the test. Use `TestClient.start` directly for independently owned clients. These fixtures isolate their own state, not page globals such as `location`. The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every namespace in both tiers. An assembled test uses the `remote` fixture; a local `TestRemote` can receive `{ settings: mock.remote.settings }`. Configure returned data and read native `.mock.calls` directly. A mutation response does not update later describe answers automatically: change `remote.settings.describe.mockResolvedValue(...)` explicitly when the scenario publishes new data. The proxy documentation owns unbuilt typing and the required build-backed local typecheck. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 4c03169b2d..92d61c8d2a 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -89,7 +89,7 @@ test('registers into the sidebar', async ({ remote, start }) => { }) ``` -`createClientTest` 使用原生 Vitest fixture:每个测试获得已加载 `remoteDefaultResponses` 的新 `mock`、等同于 `mock.remote` 的 `remote` Proxy,以及配置应答后才起机的 `start()`。重复启动共用一个 Promise,调用方必须 await 它来观察启动错误。fixture 收尾等待启动,即使断言失败也销毁客户端、检查漏配,并拒绝测试结束后保存的 `start` 调用。需要分别拥有多个客户端时直接用 `TestClient.start`。这些 fixture 隔离自己的状态,不隔离 `location` 等页面全局。 +`createClientTest` 使用原生 Vitest fixture:每个测试获得已加载 `remoteDefaultResponses` 的新 `mock`、等同于 `mock.remote` 的 `remote` Proxy,以及配置应答后才起机的 `start()`。重复启动共用一个 Promise,调用方必须 await 它来观察启动错误。产品启动只对必需名单外的未激活 entry 告警,测试默认则要求每个 roster entry 都激活;只有预期可选插件失败的用例,才向 `createClientTest` 或 `TestClient.start` 传 `allowInactiveEntries: true`。fixture 收尾等待启动,即使断言失败也销毁客户端、检查漏配,并拒绝测试结束后保存的 `start` 调用。需要分别拥有多个客户端时直接用 `TestClient.start`。这些 fixture 隔离自己的状态,不隔离 `location` 等页面全局。 两档测试的所有命名空间都使用[通用 Remote Proxy](../remote-mock/README.zh.md#remote-proxy)。装配测试使用 `remote` fixture;局部 `TestRemote` 可以接收 `{ settings: mock.remote.settings }`。直接配置返回数据,并读取原生 `.mock.calls`。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 `remote.settings.describe.mockResolvedValue(...)`。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。 diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 507c4506b0..c8a5e781ae 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -11,7 +11,7 @@ import { installConnection, type ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' -import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts' +import { assertEntriesActive, bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts' import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts' import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { act } from '@testing-library/react' @@ -43,6 +43,12 @@ export interface TestClientOptions { readonly connectTimeoutMs?: number /** The fixed access answer for each action on every resource. Default: every action is allowed. */ readonly access?: (action: string) => boolean + /** + * Tolerate inactive roster entries outside the Web client's required list, as the product boot does. Default + * false: every roster entry must activate, so a plugin that fails to import or activate, or waits on a missing + * service, rejects `start` with its audit reason. + */ + readonly allowInactiveEntries?: boolean } /** @@ -202,12 +208,14 @@ export class TestClient { * contract-free proxies over the same Connection instead. The * `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated * contribution, is dropped too, and allow-all access Hooks replace it. A - * `provide` entry for either row is refused. On any failure the context is disposed, an owned + * `provide` entry for either row is refused. Unless `allowInactiveEntries` is set, every roster entry must + * activate, stricter than the product boot, which only warns about entries outside its required list. On any + * failure the context is disposed, an owned * mount removed, and this client's hold on the shims released before the * original error is rethrown. * @param plan - roster and annotations. * @param mock - Remote mock answering every Gateway call. - * @param options - mount and readiness options. + * @param options - mount, readiness, and strictness options. * @returns the booted client. */ static async start( @@ -257,6 +265,11 @@ export class TestClient { ctx.plugin(options.access === undefined ? allowAllAccessPlugin : staticAccessPlugin(options.access)) } await bootClient({ ctx, modules: system, manifest: system.manifest }) + if (options.allowInactiveEntries !== true) { + await assertEntriesActive(ctx, system, (report) => { + throw new Error(`client-test-runtime: every roster entry must activate; pass allowInactiveEntries to tolerate it\n${report}`) + }) + } if (mountPoint.element !== undefined) { if (ctx.get('uiRenderer') === undefined) { throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`') diff --git a/packages/test-support/client-runtime/src/assembly/vitest.ts b/packages/test-support/client-runtime/src/assembly/vitest.ts index 889ee2adcb..93f6348bed 100644 --- a/packages/test-support/client-runtime/src/assembly/vitest.ts +++ b/packages/test-support/client-runtime/src/assembly/vitest.ts @@ -20,7 +20,7 @@ export interface ClientTestFixtures { * promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection. * Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned. * @param plan - roster and replacement modules, shared as configuration rather than as a running client. - * @param options - mount and readiness settings passed to `TestClient.start`. + * @param options - mount, readiness, and strictness settings passed to `TestClient.start`. * @returns Vitest's test function with test-scoped `mock` and `start` fixtures. */ export function createClientTest(plan: AssemblyPlan, options: TestClientOptions = {}): TestAPI { diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts index e51ce69892..4c4ced51eb 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -7,6 +7,8 @@ import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/inde /** The Gateway client and what it injects: the Typert registry and the Connection. */ const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry']) +/** A row on the Web client's required list, so its failure rejects the boot. */ +const RENDERER_ONLY = webApp.pick(['@deepseek-ai/dsh-client-ui-renderer']) const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks } describe('TestClient (node environment)', () => { @@ -38,16 +40,46 @@ describe('TestClient (node environment)', () => { await client.dispose() // idempotent after a failed teardown }) - it('rethrows a row that fails to apply and lets the next client boot', async () => { + it('rethrows a required row that fails to apply and lets the next client boot', async () => { const failing = { apply(): void { throw new Error('apply boom') } } - await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create())) - .rejects.toThrow(/apply boom|typert-registry/) + await expect(TestClient.start({ roster: RENDERER_ONLY, provide: { '@deepseek-ai/dsh-client-ui-renderer': failing } }, RemoteMock.create())) + .rejects.toThrow('@deepseek-ai/dsh-client-ui-renderer (required): failed') const mock = RemoteMock.create().load(remoteDefaultResponses) const client = await TestClient.start({ roster: API_ROSTER }, mock) onTestFinished(() => client.dispose()) expect(client.connection.state.getSnapshot()).toBe('connected') }) + it('rejects an optional row that fails to apply by default and names the opt-out', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const failing = { apply(): void { throw new Error('apply boom') } } + await expect(TestClient.start( + { roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, + RemoteMock.create(), + { awaitConnected: false }, + )).rejects.toThrow([ + 'client-test-runtime: every roster entry must activate; pass allowInactiveEntries to tolerate it', + 'web boot: warning: 1 entry did not activate', + '@deepseek-ai/dsh-typert-registry: failed: Error: apply boom', + ].join('\n')) + }) + + it('tolerates an inactive optional row when allowInactiveEntries is set', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const failing = { apply(): void { throw new Error('apply boom') } } + const client = await TestClient.start( + { roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, + RemoteMock.create(), + { awaitConnected: false, allowInactiveEntries: true }, + ) + onTestFinished(() => client.dispose()) + expect(warn).toHaveBeenCalledExactlyOnceWith( + 'web boot: warning: 1 entry did not activate\n@deepseek-ai/dsh-typert-registry: failed: Error: apply boom', + ) + }) + it.each(['@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access'])( 'refuses a provide entry for the %s row, whose generated Remote mount the tier replaces', async (name) => { diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts index 54ad23221d..2dd5de587e 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -195,7 +195,8 @@ describe('TestClient (jsdom)', () => { it('creates no mount element when the roster cannot be loaded', async () => { const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }]) const before = document.body.childElementCount - await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow() + await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })) + .rejects.toThrow('client-test-runtime: cannot import @deepseek-ai/dsh-client-test-runtime-missing/client') expect(document.body.childElementCount).toBe(before) })