Merge remote-tracking branch 'origin/master' into worktree/dynamic-tool-activation-pr-64796e
# Conflicts: # docs/config-catalog.md # docs/config-catalog.zh.md # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/persistence-catalog.i18n.yaml # docs/persistence-catalog.md # docs/persistence-catalog.zh.md # docs/persistence-schema.json # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/tool-calls.ts # packages/core/agent-loop/tests/tool-updates.spec.ts # packages/core/system-prompt/README.i18n.yaml # packages/core/system-prompt/README.md # packages/core/system-prompt/README.zh.md # packages/core/tool-executor-ptc/src/ptc.ts # packages/core/tool-executor-ptc/tests/ptc.spec.ts # packages/core/tools/README.i18n.yaml # packages/core/tools/README.md # packages/core/tools/README.zh.md # packages/core/tools/src/index.ts # packages/extensions/tool-cordis/src/api-catalog.ts # packages/plan/plan-mode/tests/plan-mode.spec.ts # snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl
This commit is contained in:
commit
207b30d299
813 files changed
+16893
-6315
No files matched your search
+2
-2
@@ -24,8 +24,8 @@
|
||||
en: 26e4b71366bebd1b
|
||||
zh: 10598e12b28c586e
|
||||
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification:
|
||||
en: fbd101d4576afa92
|
||||
zh: 35f5d3c2f6cad6a8
|
||||
en: 983da6d0c09f5c10
|
||||
zh: ca7c86117a1a4d5f
|
||||
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered:
|
||||
en: c4d86feb135d2ac4
|
||||
zh: 67c56c0bc2213949
|
||||
|
||||
@@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making
|
||||
|
||||
## Verification
|
||||
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
||||
|
||||
No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
## 验证
|
||||
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
|
||||
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
|
||||
|
||||
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
|
||||
|
||||
|
||||
+4
-4
@@ -54,8 +54,8 @@
|
||||
en: 7865e159528e2d30
|
||||
zh: 1399d54632f4d926
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
|
||||
en: 41d7ac1faf2448d7
|
||||
zh: 6d523895a9abae68
|
||||
en: a928a63e8c36177c
|
||||
zh: c943c1fdc8c4bb45
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
|
||||
en: 7be28cd7e19e5377
|
||||
zh: 30b5aee957271d6c
|
||||
@@ -81,8 +81,8 @@
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 048067eb02e6bf8a
|
||||
zh: 12508b59f6b206b8
|
||||
en: 2279a8c153cc5b89
|
||||
zh: 63c0b6462722f0a3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| Behavior | Detail |
|
||||
|---|---|
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts |
|
||||
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
|
||||
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
|
||||
| Endpoint actions only | `event:<name>` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
|
||||
@@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
|
||||
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 |
|
||||
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
|
||||
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
|
||||
| 只含端点 action | `event:<name>` action 不进入快照;前端只据端点 action 决定入口显隐 |
|
||||
@@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
|
||||
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-when-access.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-when-access.md
|
||||
/agent-note-whenaccess-registration-that-follows-access:
|
||||
en: 2ef7cfef514c138a
|
||||
zh: 064e3aecbfafcd9c
|
||||
/agent-note-whenaccess-registration-that-follows-access/problem:
|
||||
en: 4f88d3ee7868eaa5
|
||||
zh: 72a4ce2dde13bef7
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess:
|
||||
en: c168de20989cf9a4
|
||||
zh: bf3b0361beea083d
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
|
||||
en: 73577ed282c656f3
|
||||
zh: 2f60a6824218e693
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: ddc432b693608163
|
||||
zh: 620294d8812b0a31
|
||||
/agent-note-whenaccess-registration-that-follows-access/consequences:
|
||||
en: de44fa92c49bc335
|
||||
zh: 34436fe971ab013f
|
||||
/agent-note-whenaccess-registration-that-follows-access/related:
|
||||
en: 89073ffbfcff0641
|
||||
zh: 736a180d13a08981
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: whenAccess registration that follows access
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-when-access.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering:
|
||||
|
||||
| Where | Existing capability | Gap |
|
||||
|---|---|---|
|
||||
| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
|
||||
| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable<boolean>` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
|
||||
|
||||
Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change.
|
||||
|
||||
## Decision
|
||||
|
||||
### whenAccess
|
||||
|
||||
The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook:
|
||||
|
||||
| Interface | Provider | Matching Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`.
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads |
|
||||
| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration |
|
||||
| Stream failure | A non-owner's answer turns false and the installed effects dispose |
|
||||
| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask |
|
||||
| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### Plugin migration
|
||||
|
||||
| Plugin | Change | Action |
|
||||
|---|---|---|
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
|
||||
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` |
|
||||
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
|
||||
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change.
|
||||
|
||||
**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
|
||||
|
||||
**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest.
|
||||
|
||||
**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Consequence | Note |
|
||||
|---|---|
|
||||
| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration |
|
||||
| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
|
||||
| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
|
||||
| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
|
||||
|
||||
## Related
|
||||
|
||||
- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: 按访问状态注册的 whenAccess
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-when-access.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染:
|
||||
|
||||
| 位置 | 现有能力 | 缺口 |
|
||||
|---|---|---|
|
||||
| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
|
||||
| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable<boolean>` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
|
||||
|
||||
没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。
|
||||
|
||||
## 决策
|
||||
|
||||
### whenAccess
|
||||
|
||||
JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供:
|
||||
|
||||
| 接口 | 提供者 | 对应 Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 |
|
||||
| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 |
|
||||
| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 |
|
||||
| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 |
|
||||
| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### 插件迁移
|
||||
|
||||
| 插件 | 改法 | action |
|
||||
|---|---|---|
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` |
|
||||
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
|
||||
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。
|
||||
|
||||
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
|
||||
|
||||
**插件包清单声明访问性**:在包清单上定义访问控制概念。
|
||||
|
||||
**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
|
||||
|
||||
## 后果
|
||||
|
||||
| 后果 | 说明 |
|
||||
|---|---|
|
||||
| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 |
|
||||
| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
|
||||
| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
|
||||
| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。
|
||||
@@ -188,6 +188,10 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_mcp_resources
|
||||
plugin_dsh_base_tools["tools<br/>@deepseek-ai/dsh-tools"]
|
||||
cfg --> plugin_dsh_base_tools
|
||||
plugin_dsh_base_tool_executor_native["tool-executor-native<br/>@deepseek-ai/dsh-tool-executor-native"]
|
||||
cfg --> plugin_dsh_base_tool_executor_native
|
||||
plugin_dsh_base_tool_executor_ptc["tool-executor-ptc<br/>@deepseek-ai/dsh-tool-executor-ptc"]
|
||||
cfg --> plugin_dsh_base_tool_executor_ptc
|
||||
plugin_dsh_base_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
|
||||
cfg --> plugin_dsh_base_system_prompt
|
||||
plugin_dsh_base_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
|
||||
@@ -292,6 +296,8 @@ flowchart LR
|
||||
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
|
||||
| `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` |
|
||||
| `tools` | `@deepseek-ai/dsh-tools` |
|
||||
| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` |
|
||||
| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` |
|
||||
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
|
||||
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
|
||||
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- id: tool-executor-native
|
||||
disabled: true
|
||||
|
||||
- id: tool-executor-ptc
|
||||
disabled: false
|
||||
@@ -155,6 +155,7 @@
|
||||
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 8d2cfff9bab7327f
|
||||
zh: 21515ab6e6798504
|
||||
/dsh-cli-behavior-reference/web-profile:
|
||||
en: 850434e8ca162221
|
||||
zh: 4893613f97a21eb8
|
||||
en: 149c5571acd51baf
|
||||
zh: 300e3c2425273a92
|
||||
/dsh-cli-behavior-reference/shared-deployment-behavior:
|
||||
en: f40edcf903a51f3f
|
||||
zh: 8a9a915451047c8b
|
||||
|
||||
@@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root
|
||||
|
||||
New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
|
||||
|
||||
`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place.
|
||||
Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place.
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ dsh web --help
|
||||
|
||||
基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
|
||||
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
|
||||
@@ -1268,6 +1268,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
['session-title', '@deepseek-ai/dsh-session-title'],
|
||||
['system-prompt', '@deepseek-ai/dsh-system-prompt'],
|
||||
['tools', '@deepseek-ai/dsh-tools'],
|
||||
['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'],
|
||||
['mcp-resources', '@deepseek-ai/dsh-mcp-resources'],
|
||||
['agent', '@deepseek-ai/dsh-agent'],
|
||||
['llm-retry', '@deepseek-ai/dsh-llm-retry'],
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -52,7 +53,7 @@ try {
|
||||
&& message.source.kind === 'skill-catalog')?.content
|
||||
: undefined
|
||||
const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge')
|
||||
const result = await ctx.tools.execute({
|
||||
const result = await executeToolFixture(ctx, {
|
||||
callId: ToolCallId('dsh-badge-snapshot'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
|
||||
|
||||
it('loads the actual badge asset only while its bundle is selected', async () => {
|
||||
const owner = await agent()
|
||||
const load = (callId: string) => ctx.tools.execute({
|
||||
const load = (callId: string) => executeToolFixture(ctx, {
|
||||
callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' },
|
||||
agent: owner.agent, signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
@@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => {
|
||||
'--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.',
|
||||
],
|
||||
tsconfigPath,
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' },
|
||||
inspect: async (cwd) => {
|
||||
const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
@@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const first = await runLoaderSmoke({
|
||||
label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath,
|
||||
binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath,
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' },
|
||||
})
|
||||
const sessionId = parseJsonl(first.stdout)[0]?.sessionId
|
||||
if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity')
|
||||
@@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const second = await runLoaderSmoke({
|
||||
label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath,
|
||||
binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath,
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' },
|
||||
inspect: async (directory) => {
|
||||
const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
|
||||
@@ -40,7 +40,7 @@ interface ResolutionEvidence {
|
||||
sourceToolsCjs: string | null
|
||||
sourceLeafCjs: string
|
||||
sourceLeafExplicitCjs: string
|
||||
scheduler: boolean
|
||||
executor: boolean
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
const staleTools = join(root, 'stale-tools')
|
||||
const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools')
|
||||
await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, {
|
||||
'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')",
|
||||
'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')",
|
||||
'index.cjs': "throw new Error('STALE_DSH_TOOLS')",
|
||||
})
|
||||
const sourceDir = join(sourcePackageDir, 'src')
|
||||
@@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
'probe.mjs': [
|
||||
"import { createRequire } from 'node:module'",
|
||||
"import { getEnvironmentData } from 'node:worker_threads'",
|
||||
"import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
|
||||
"import Tools from '@deepseek-ai/dsh-tools'",
|
||||
`import { leaf } from '${leafName}'`,
|
||||
`import { leaf as bridgeLeaf } from '${bridgeName}'`,
|
||||
`import { external } from '${externalName}'`,
|
||||
@@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
' sourceToolsInstance: ctx.tools instanceof SourceTools,',
|
||||
` profileToolsCjs: ${toolsCjsExpression},`,
|
||||
' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,',
|
||||
" scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
|
||||
" executor: typeof ctx.tools.execute === 'function',",
|
||||
' modules: [...ctx.loader.internal.loadCache.keys()]',
|
||||
' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
|
||||
' }',
|
||||
@@ -292,7 +292,7 @@ export function testProfileResolution(mode: ExampleMode): void {
|
||||
expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename)
|
||||
expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs')))
|
||||
expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs)
|
||||
expect(evidence.scheduler).toBe(true)
|
||||
expect(evidence.executor).toBe(true)
|
||||
expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
|
||||
for (const name of ['tools', 'agent-loop']) {
|
||||
const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -96,7 +97,7 @@ function runCode(
|
||||
signal: AbortSignal = testToolSignal,
|
||||
agent?: Agent,
|
||||
): Promise<ToolExecutionResult> {
|
||||
return harness.tools.execute({
|
||||
return executeModelToolFixture(harness, {
|
||||
callId: ToolCallId(`keyless-code-${++keylessCall}`),
|
||||
name: RUN_CODE_NAME,
|
||||
arguments: { code, description: 'Run the e2e program' },
|
||||
@@ -308,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) };
|
||||
`, testToolSignal, agent))
|
||||
|
||||
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] })
|
||||
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE
|
||||
DSH_HOME: join(root, 'home'),
|
||||
DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: 'native',
|
||||
DSH_CLI_MOCK_FAILURE: '0',
|
||||
},
|
||||
input: '',
|
||||
|
||||
@@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
|
||||
retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig,
|
||||
models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }],
|
||||
} },
|
||||
{ id: 'tools', config: { mode: 'native' } },
|
||||
{ id: 'session-log-deepseek', disabled: true },
|
||||
{ id: 'plugin-package-inventory-deepseek', disabled: true },
|
||||
{ insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] },
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
{ label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false },
|
||||
{ label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
|
||||
{ label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
|
||||
{ label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true },
|
||||
{ label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true },
|
||||
])('$label', async (scenario) => {
|
||||
const { envValue, editorEnabled } = scenario
|
||||
const ptc = 'ptc' in scenario && scenario.ptc
|
||||
@@ -92,6 +92,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
'--profile',
|
||||
'sdk',
|
||||
...(editorEnabled ? ['--patch', editorPatch] : []),
|
||||
...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []),
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
@@ -99,7 +100,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
DSH_HOME: join(root, '.dsh'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: ptc ? 'ptc' : 'native',
|
||||
DSH_TOOLS_MODE: ptc ? 'native' : 'ptc',
|
||||
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
|
||||
|
||||
@@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
|
||||
], {
|
||||
cwd: root, env: {
|
||||
PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '',
|
||||
},
|
||||
timeout: 110_000, killSignal: 'SIGKILL', reject: false,
|
||||
|
||||
@@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
@@ -47,13 +48,14 @@ it.each([
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const seen: SdkUserQuestionRequest[] = []
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
const harness = state.harness = new DeepSeekHarness({
|
||||
cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000,
|
||||
patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [],
|
||||
onUserQuestion: async (request, { signal }) => {
|
||||
expect(signal.aborted).toBe(false)
|
||||
seen.push(request)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Test-only IPC assertions over real Creator presets and profile management. */
|
||||
import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
|
||||
export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets']
|
||||
|
||||
export function apply(ctx, config) {
|
||||
@@ -30,7 +32,7 @@ export function apply(ctx, config) {
|
||||
try {
|
||||
const first = await make(`${phase}-first`)
|
||||
const before = names(first)
|
||||
const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first,
|
||||
const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first,
|
||||
callId: `${phase}-manage`, signal: new AbortController().signal })
|
||||
ctx.permissionPresets.set(first.session, 'workspace-write')
|
||||
first.session.append('turn/start', { turn: 1 })
|
||||
@@ -45,7 +47,7 @@ export function apply(ctx, config) {
|
||||
const second = await make(`${phase}-second`)
|
||||
const after = names(first)
|
||||
const other = names(second)
|
||||
const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first,
|
||||
const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first,
|
||||
callId: `${phase}-ping`, signal: new AbortController().signal })
|
||||
const removed = phase === 'restart'
|
||||
? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts'
|
||||
import { createRequire } from 'node:module'
|
||||
@@ -629,7 +630,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(scoped).toContain('project-proof')
|
||||
|
||||
// The preset's own loader tool resolves the global-layer skill.
|
||||
const loaded = await ctx.tools.execute({
|
||||
const loaded = await executeToolFixture(ctx, {
|
||||
callId: ToolCallId('preset-skills-load'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
@@ -787,7 +788,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
]))
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
const listed = await copyCtx.tools.execute({
|
||||
const listed = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId('copied-preset-inspect-list'),
|
||||
name: 'cordis_inspect_list',
|
||||
arguments: {},
|
||||
@@ -802,7 +803,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
// The `Tool` provider is the one built over the host context: it
|
||||
// answers with the shared registry's view of the REQUESTING agent,
|
||||
// so the copy sees its own preset's tools, not the host's empty set.
|
||||
const queried = await copyCtx.tools.execute({
|
||||
const queried = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId('copied-preset-inspect-tools'),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Tool', method: 'listTools' },
|
||||
@@ -819,12 +820,12 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
expect(queriedText).toContain(`"name": "${toolName}"`)
|
||||
}
|
||||
|
||||
// The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config,
|
||||
// The `Config` provider reads the booted profile tree: the Agent loop declares a Config,
|
||||
// and the bootstrap include row is a carrier. The name filter keeps each page small.
|
||||
type ConfigRow = { id: string; patchId: string; name: string; status: string }
|
||||
type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } }
|
||||
const listConfigs = async (input: Record<string, string | number>, callId: string): Promise<ConfigPage['data']> => {
|
||||
const page = await copyCtx.tools.execute({
|
||||
const page = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId(callId),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input },
|
||||
@@ -834,16 +835,16 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
expect(page.isError, resultText(page)).toBe(false)
|
||||
return (JSON.parse(resultText(page)) as ConfigPage).data
|
||||
}
|
||||
const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs')
|
||||
const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs')
|
||||
const toolsRow = toolsRows.entries[0]
|
||||
expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' })
|
||||
expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' })
|
||||
expect(toolsRows).toMatchObject({ total: 1, nextOffset: null })
|
||||
const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes')
|
||||
expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' })
|
||||
const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page')
|
||||
expect(firstPage.entries).toHaveLength(5)
|
||||
expect(firstPage.nextOffset).toBe(5)
|
||||
const projected = await copyCtx.tools.execute({
|
||||
const projected = await executeToolFixture(copyCtx, {
|
||||
callId: ToolCallId('copied-preset-inspect-config'),
|
||||
name: 'cordis_inspect_query',
|
||||
arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } },
|
||||
@@ -855,7 +856,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
|
||||
const { data } = JSON.parse(resultText(projected)) as { data: Projected }
|
||||
expect(data.status).toBe('schema')
|
||||
expect(data.schema.$defs).toHaveProperty('loaderExpression')
|
||||
expect(JSON.stringify(data.schema)).toContain('"mode"')
|
||||
expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"')
|
||||
} finally {
|
||||
await copied.dispose()
|
||||
await shipped.dispose()
|
||||
@@ -954,7 +955,7 @@ describe('a delegated child', () => {
|
||||
] as const
|
||||
for (const [name, args] of calls) {
|
||||
expect(toolNames(ctx, child.agent)).toContain(name)
|
||||
const result = await ctx.tools.execute({
|
||||
const result = await executeToolFixture(ctx, {
|
||||
callId: ToolCallId(`child-${name}`), signal: new AbortController().signal,
|
||||
name, arguments: args, agent: child.agent,
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*"
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-executor-native": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ async function main(): Promise<void> {
|
||||
if (process.connected) process.send?.({ type: 'platform-session', session })
|
||||
})
|
||||
const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) })
|
||||
}
|
||||
|
||||
/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
@@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => {
|
||||
expect('default' in desktopOffice).toBe(false)
|
||||
expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice)
|
||||
const modules = new Map<string, unknown>([
|
||||
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime],
|
||||
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor],
|
||||
['skills', SkillRegistry], ['office', desktopOffice],
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 5f2d25a1d8472647
|
||||
zh: 2acb5c5d7d1917e2
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: 24387a07ae4eb8e7
|
||||
zh: 0119352685cf273c
|
||||
en: bb35a022c37955c9
|
||||
zh: ab2ef22d398d83a0
|
||||
/deepseek-harness-desktop/key-technical-decisions:
|
||||
en: 3724c53da4f7647b
|
||||
zh: 76ae549d43c99bb4
|
||||
@@ -57,14 +57,14 @@
|
||||
en: 9b90a2651a6d2b52
|
||||
zh: 86d4d9c4358aad80
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: 87db37529b51da62
|
||||
zh: 4fc0d752e437e7d6
|
||||
en: 67bf0a18faa7aa76
|
||||
zh: 782e14ce62bb939f
|
||||
/deepseek-harness-desktop/updates/local-desktop-settings:
|
||||
en: 2e57457c44c85b5f
|
||||
zh: 5b8510a3dc4c6c9d
|
||||
/deepseek-harness-desktop/updates/mandatory-update-policy:
|
||||
en: 54596bef0706d305
|
||||
zh: 48737302dcf3c939
|
||||
en: 182a9c6705213e13
|
||||
zh: 681762f83490f4c6
|
||||
/deepseek-harness-desktop/updates/local-updater-qualification:
|
||||
en: 68ec2f8bd5763ea6
|
||||
zh: 23b932dfe8503602
|
||||
@@ -72,8 +72,8 @@
|
||||
en: 8264b135871e8978
|
||||
zh: 0939aafb1b2961fc
|
||||
/deepseek-harness-desktop/known-limitations:
|
||||
en: c63d4dc7a9104954
|
||||
zh: a4c676e5b46a6ac0
|
||||
en: b1eeadfefb338056
|
||||
zh: 28683fbdf1e98685
|
||||
/deepseek-harness-desktop/dev-note:
|
||||
en: 8e26b460ba31f21b
|
||||
zh: fbcd0307b3867125
|
||||
@@ -70,7 +70,7 @@ Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and th
|
||||
|
||||
Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon.
|
||||
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quit caused by closing the welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
|
||||
The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
|
||||
|
||||
@@ -271,9 +271,11 @@ On Windows, the downloaded-update confirmation explains that the application clo
|
||||
|
||||
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
|
||||
|
||||
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
|
||||
Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. The Windows caption lies outside the view; its native and product Web controls remain operable. Caption clicks can toggle the sidebar, create or reuse a blank Session, and switch the underlying page. Application menu actions such as About or CLI command management can replace and cancel the current ordinary prompt; this does not approve installation or clear mandatory policy. Product keyboard shortcuts and editing-key forwarding remain blocked while the view is attached.
|
||||
|
||||
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
|
||||
Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline.
|
||||
|
||||
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page.
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
|
||||
|
||||
@@ -330,10 +332,12 @@ When permitted, login is offered during user-initiated checks and the packaged a
|
||||
|
||||
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
|
||||
|
||||
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
|
||||
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner follows the [normal window-close behavior](#closing-the-window-and-quitting). View loading failures retain policy and input blocking without changing the owner title. A native recovery dialog appears only while the owner is visible and focused, showing the policy explanation and available target version. Open download page uses the policy URL after the existing origin validation and is omitted when no approved URL is available; Quit uses normal application exit confirmation. No Cancel button is offered; non-action dismissals retain blocking and reopen recovery. Hiding the owner, policy clearance, successful loading, and disposal dismiss the recovery dialog; obsolete responses have no effect. This recovery covers the isolated view, not the Windows embedded frame. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
|
||||
|
||||
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
|
||||
|
||||
Selecting Check for Updates while mandatory policy is already blocking focuses its existing interface. If a manual check finishes with mandatory policy active, it also restores, shows, and focuses the owner even after the user has switched applications, minimized, or hidden it. This explicit foreground request is retained independently of the view carrier; the ordinary-result path does not make that request. Focusing the interface does not authorize downloading, installation, or task stopping. The asynchronous mandatory-result foreground path has not received a dedicated background/fullscreen manual qualification.
|
||||
|
||||
Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required.
|
||||
|
||||
### Local updater qualification
|
||||
@@ -375,7 +379,7 @@ When an expired account returns to Welcome, the main process retains a one-time
|
||||
|
||||
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
|
||||
|
||||
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
|
||||
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Update policy and ordinary update prompts refuse Platform admission, including a pending open whose storage cleanup completes after blocking starts. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
|
||||
|
||||
Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin, token, and resolved language.
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会
|
||||
|
||||
Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。首次隐藏前复用更新弹窗,显示“正在运行的任务不会中断,可在系统托盘中重新打开窗口”和“确认”按钮。确认后隐藏窗口,并在 Electron userData 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。
|
||||
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
|
||||
强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
|
||||
|
||||
@@ -271,9 +271,11 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
|
||||
|
||||
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
|
||||
|
||||
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
|
||||
普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 顶栏位于视图覆盖范围外,其中的原生及产品 Web 控件仍可操作。点击顶栏可切换侧栏、创建或复用空 Session,并改变底层页面。“关于”或 CLI 命令管理等应用菜单操作可替换并取消当前普通提示,但不代表批准安装,也不会清除强更策略。视图挂载期间仍阻断产品键盘快捷键和编辑按键转发。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
|
||||
普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
|
||||
|
||||
@@ -330,10 +332,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett
|
||||
|
||||
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
|
||||
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口遵循[常规关窗行为](#closing-the-window-and-quitting)。视图加载失败保留策略与输入阻断,不改写父窗口标题。仅当所属窗口可见且有焦点时显示原生恢复提示,包含策略说明与可用目标版本。打开下载页面使用现有来源校验后的策略 URL,缺少获准地址时不显示该按钮;退出走正常应用退出确认。不提供取消按钮,非操作性关闭保留阻断并重新显示恢复提示。隐藏窗口、策略解除、加载成功或释放控制器会撤销提示,过期响应无效。此恢复覆盖独立视图,不包含 Windows 内嵌 frame。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
|
||||
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
|
||||
|
||||
已处于强更阻断状态时,选择“检查更新”会聚焦现有界面。手动检查完成时若强更策略已生效,也会恢复、显示并聚焦所属窗口,即使用户已切换应用、最小化或隐藏窗口。此显式置前行为独立于视图承载方式保留;普通检查结果路径不发起该请求。聚焦界面不代表批准下载、安装或停止任务。异步强更结果置前路径尚未单独完成后台/全屏人工验证。
|
||||
|
||||
后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。
|
||||
|
||||
### 本地 updater 验证
|
||||
@@ -375,7 +379,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
|
||||
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
|
||||
|
||||
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
|
||||
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。强更策略或普通更新提示阻止打开 Platform 视图;清理存储期间开始阻断时,待打开视图也不会挂载。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
|
||||
|
||||
私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
#status, #version { margin-top: 12px; color: #555962; }
|
||||
#status, #version { margin-top: 12px; color: var(--dsw-alias-label-secondary); }
|
||||
#status:empty, #version:empty { display: none; }
|
||||
#error { margin-top: 12px; color: #d93025; }
|
||||
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; }
|
||||
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; }
|
||||
#error { margin-top: 12px; color: var(--dsw-alias-state-error-primary); }
|
||||
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/20px monospace; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; resize: vertical; }
|
||||
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: var(--dsw-alias-label-secondary); background: transparent; }
|
||||
#update, #refresh { border-radius: 100px; }
|
||||
#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; }
|
||||
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; }
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
|
||||
#copy-message { color: #d93025 !important; }
|
||||
#fallback { border-top: 1px solid var(--dsw-alias-border-l2); margin-top: 12px; padding-top: 12px; }
|
||||
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: var(--dsw-alias-link); font-size: 12px; }
|
||||
#copy-message { color: var(--dsw-alias-state-error-primary) !important; }
|
||||
#manual-copy { display: block; margin-top: 8px; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: var(--dsw-alias-border-l2); accent-color: var(--dsw-alias-button-primary-fill); }
|
||||
|
||||
#progress::-webkit-progress-bar { background: #e6e7eb; }
|
||||
#progress::-webkit-progress-value { background: #0f1115; }
|
||||
#progress::-moz-progress-bar { background: #0f1115; }
|
||||
#progress::-webkit-progress-bar { background: var(--dsw-alias-border-l2); }
|
||||
#progress::-webkit-progress-value { background: var(--dsw-alias-button-primary-fill); }
|
||||
#progress::-moz-progress-bar { background: var(--dsw-alias-button-primary-fill); }
|
||||
@media (hover: hover) {
|
||||
#actions button:not(.primary):hover:not(:disabled), #copy:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="design-platform.css">
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
<link rel="stylesheet" href="mandatory-update.css">
|
||||
</head>
|
||||
<body class="update-dialog">
|
||||
<script src="shell-theme.js"></script>
|
||||
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
|
||||
<header><h1 id="title"></h1></header>
|
||||
<p id="detail"></p>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Shell documents follow Electron's app-resolved palette before displaying their content. */
|
||||
const palette = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
|
||||
applyPalette()
|
||||
palette.addEventListener('change', applyPalette)
|
||||
window.addEventListener('pagehide', () => { palette.removeEventListener('change', applyPalette) }, { once: true })
|
||||
@@ -1,31 +1,37 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color-scheme: light dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: #0f1115;
|
||||
background: transparent;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; }
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); }
|
||||
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
|
||||
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; background: transparent; color: var(--dsw-alias-label-primary); color-scheme: light; }
|
||||
body[data-ds-dark-theme] { color-scheme: dark; }
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--dsw-alias-bg-mask-1); }
|
||||
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: var(--dsw-alias-bg-layer-1); box-shadow: none; }
|
||||
body[data-ds-dark-theme] main { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||
main:focus { outline: none; }
|
||||
h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; }
|
||||
p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; }
|
||||
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; }
|
||||
.primary { background: #0f1115; color: #fff; }
|
||||
.secondary { background: #f5f5f5; color: inherit; }
|
||||
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: var(--dsw-alias-bg-layer-1); }
|
||||
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; color: inherit; cursor: pointer; }
|
||||
.primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); }
|
||||
|
||||
.secondary { background: var(--dsw-alias-button-primary-dimmed); color: inherit; }
|
||||
.close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; }
|
||||
.close img { width: 14px; height: 14px; }
|
||||
button:hover { filter: brightness(0.95); }
|
||||
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
|
||||
.close::before { content: ""; width: 14px; height: 14px; background: currentColor; mask: url("update-close.svg") center / contain no-repeat; }
|
||||
|
||||
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 3px; }
|
||||
details { margin-top: 12px; font-size: 13px; line-height: 20px; }
|
||||
summary { cursor: pointer; color: #5b6472; }
|
||||
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
summary { cursor: pointer; color: var(--dsw-alias-label-secondary); }
|
||||
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
[hidden] { display: none !important; }
|
||||
body.update-dialog { opacity: 0; transition: opacity 150ms ease; }
|
||||
body.update-dialog.visible { opacity: 1; }
|
||||
@starting-style { body.update-dialog.visible { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } }
|
||||
@media (hover: hover) {
|
||||
.primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
|
||||
.secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="design-platform.css">
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
</head>
|
||||
<body class="update-dialog">
|
||||
<script src="shell-theme.js"></script>
|
||||
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"></button></header>
|
||||
<p id="detail"></p>
|
||||
<details id="technical-details" hidden>
|
||||
<summary id="technical-details-label"></summary>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<script src="../lib/welcome/welcome.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="shell-theme.js"></script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,7 +29,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi
|
||||
directories: { ...config.directories, output: join(run.root, version, 'installer') },
|
||||
extraMetadata: { ...config.extraMetadata, name: `dsh-update-test-${run.id}`, version, main: 'qualification-bootstrap.mjs' },
|
||||
files: [
|
||||
{ from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
|
||||
{ from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'lib/welcome/design-platform.css', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
|
||||
'package.json',
|
||||
...config.files.filter((file): file is string => typeof file === 'string' && file.startsWith('!')),
|
||||
{ from: dsh, to: 'dsh', filter: ['**/*'] },
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function prepareInstalledUpdateApplication(manifest: string, source
|
||||
const modules = (await readdir(join(source, 'lib'), { withFileTypes: true }))
|
||||
.filter(entry => /\.(?:js|cjs)$/u.test(entry.name))
|
||||
if (modules.some(entry => !entry.isFile())) throw new Error('installed update: application modules must be regular files')
|
||||
const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer')].sort()
|
||||
const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer'), 'lib/welcome/design-platform.css'].sort()
|
||||
for (const required of ['lib/main.js', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) {
|
||||
if (!paths.includes(required)) throw new Error('installed update: rebuild Desktop before freezing application files')
|
||||
}
|
||||
@@ -76,7 +76,8 @@ export async function verifyInstalledUpdateApplication(root: string): Promise<st
|
||||
if (typeof entry !== 'object' || entry === null || !('path' in entry) || !('sha256' in entry)
|
||||
|| typeof entry.path !== 'string' || typeof entry.sha256 !== 'string'
|
||||
|| entry.path.includes('\\') || entry.path.includes(':') || entry.path.split('/').some(part => ['', '.', '..'].includes(part))
|
||||
|| !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path)
|
||||
|| (entry.path !== 'lib/welcome/design-platform.css'
|
||||
&& !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path))
|
||||
|| !/^[a-f0-9]{64}$/u.test(entry.sha256) || paths.has(entry.path)) {
|
||||
throw new Error('installed update: invalid application file inventory')
|
||||
}
|
||||
@@ -87,7 +88,7 @@ export async function verifyInstalledUpdateApplication(root: string): Promise<st
|
||||
}
|
||||
const actual = await applicationFiles(join(directory, 'files'))
|
||||
if (actual.length !== paths.size || actual.some(path => !paths.has(path))
|
||||
|| ['lib/main.js', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
|
||||
|| ['lib/main.js', 'lib/welcome/design-platform.css', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
|
||||
throw new Error('installed update: frozen application inventory has missing or additional files')
|
||||
}
|
||||
return join(directory, 'files')
|
||||
|
||||
@@ -12,12 +12,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const palette = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const syncPalette = (): void => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
|
||||
syncPalette()
|
||||
palette.addEventListener('change', syncPalette)
|
||||
window.addEventListener('pagehide', () => { palette.removeEventListener('change', syncPalette) }, { once: true })
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (container === null) throw new Error('desktop welcome: missing React root')
|
||||
const root = createRoot(container)
|
||||
|
||||
@@ -150,6 +150,8 @@ export const en = {
|
||||
mandatoryPage: 'Download from the official website',
|
||||
mandatoryCopy: 'Copy download link',
|
||||
mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.',
|
||||
mandatoryViewDownload: 'Open download page',
|
||||
mandatoryViewPageFailed: 'Could not open the download page. Please try again.',
|
||||
mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.',
|
||||
mandatoryReady: 'Update ready',
|
||||
mandatoryVersion: 'New version: {version}',
|
||||
@@ -322,6 +324,8 @@ export const zh = {
|
||||
mandatoryPage: '前往官网下载',
|
||||
mandatoryCopy: '复制下载链接',
|
||||
mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。',
|
||||
mandatoryViewDownload: '打开下载页面',
|
||||
mandatoryViewPageFailed: '无法打开下载页面,请重试',
|
||||
mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。',
|
||||
mandatoryReady: '更新已准备就绪',
|
||||
mandatoryVersion: '新版本:{version}',
|
||||
@@ -492,6 +496,8 @@ export const ja = {
|
||||
mandatoryPage: '公式サイトからダウンロード',
|
||||
mandatoryCopy: 'ダウンロードリンクをコピー',
|
||||
mandatoryPageFailed: '公式ダウンロードページを開けませんでした。リンクをコピーしてブラウザで開いてください。',
|
||||
mandatoryViewDownload: 'ダウンロードページを開く',
|
||||
mandatoryViewPageFailed: 'ダウンロードページを開けませんでした。もう一度お試しください。',
|
||||
mandatoryActionFailed: '操作に失敗しました。もう一度お試しください。アップデートが完了すると、アプリを再度使用できます。',
|
||||
mandatoryReady: 'アップデートの準備完了',
|
||||
mandatoryVersion: '新しいバージョン: {version}',
|
||||
|
||||
@@ -442,7 +442,8 @@ async function main(): Promise<void> {
|
||||
return next.promise
|
||||
}
|
||||
const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'),
|
||||
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin')
|
||||
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin',
|
||||
owner => isMandatory() || updateOverlays.input(owner).blocked)
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
@@ -668,7 +669,8 @@ async function main(): Promise<void> {
|
||||
protocol.handle(SCHEME, (request) => {
|
||||
const url = new URL(request.url)
|
||||
// Shell-owned documents live in the application bundle and never pass through the Host.
|
||||
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
|
||||
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(),
|
||||
url.pathname === '/design-platform.css' ? 'lib/welcome' : 'renderer'))
|
||||
if (url.hostname === 'app') {
|
||||
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
|
||||
|| ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */
|
||||
|
||||
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import { app, type BrowserWindow, clipboard, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
|
||||
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
|
||||
import type { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import type { DesktopUpdateOverlay, DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import { DesktopUpdateAttention } from './update-attention.ts'
|
||||
|
||||
/** A renderer action never carries a URL or authorizes a different version. */
|
||||
@@ -47,13 +47,15 @@ export interface MandatoryUpdateWindowOptions {
|
||||
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
|
||||
const page = 'dsh-app://shell/mandatory-update.html'
|
||||
|
||||
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
|
||||
/** Shell-owned update presentation inside the native owner; policy and installation approval remain main-owned. */
|
||||
export class DesktopMandatoryUpdateWindow {
|
||||
private readonly embedded = process.platform === 'win32'
|
||||
private embeddedParent: BrowserWindow | undefined
|
||||
private embeddedBlocking = false
|
||||
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
|
||||
private window: BrowserWindow | undefined
|
||||
private window: DesktopUpdateOverlay | undefined
|
||||
private surfaceParent: BrowserWindow | undefined
|
||||
private recovery: { load: () => Promise<void>; dismiss: () => void; dispose: () => void } | undefined
|
||||
private closing: ReturnType<typeof setTimeout> | undefined
|
||||
private disposed = false
|
||||
private error: string | undefined
|
||||
@@ -110,7 +112,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
|
||||
/** Active modal used as the owner of shell installation-confirmation dialogs. */
|
||||
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window }
|
||||
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.surfaceParent }
|
||||
|
||||
/**
|
||||
* @param version - Updater-owned target, already downloaded and verified.
|
||||
@@ -145,6 +147,15 @@ export class DesktopMandatoryUpdateWindow {
|
||||
/** Publish current status, create the block immediately, or close it only after policy clearance. */
|
||||
sync(): void {
|
||||
if (this.disposed) return
|
||||
if (!this.embedded && this.window !== undefined && this.surfaceParent !== this.options.parent()) {
|
||||
this.recovery?.dispose()
|
||||
this.recovery = undefined
|
||||
this.finishConfirmation(false)
|
||||
const previous = this.window
|
||||
this.window = undefined
|
||||
this.surfaceParent = undefined
|
||||
previous.destroy()
|
||||
}
|
||||
if (this.embedded) {
|
||||
const parent = this.options.parent()
|
||||
if (parent !== this.embeddedParent) {
|
||||
@@ -158,6 +169,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
if (parent === undefined || parent.isDestroyed()) return
|
||||
}
|
||||
if (!this.options.policy().blocking) {
|
||||
this.recovery?.dismiss()
|
||||
this.finishConfirmation(false)
|
||||
this.attention.reset()
|
||||
this.clearNavigation()
|
||||
@@ -168,7 +180,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
window.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
this.closing = setTimeout(() => {
|
||||
this.closing = undefined
|
||||
if (this.window === window) this.window = undefined
|
||||
if (this.window === window) { this.window = undefined; this.surfaceParent = undefined }
|
||||
window.destroy()
|
||||
}, 150)
|
||||
}
|
||||
@@ -190,24 +202,23 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
if (this.window === undefined) {
|
||||
const parent = this.options.parent()
|
||||
if (parent === undefined) return
|
||||
const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
|
||||
if (parent === undefined || parent.isDestroyed()) return
|
||||
const window = this.options.overlays.create(parent, this.options.preload)
|
||||
this.window = window
|
||||
window.setMenu(null)
|
||||
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
|
||||
window.on('closed', () => { if (this.window === window) this.window = undefined })
|
||||
this.surfaceParent = parent
|
||||
window.webContents.once('destroyed', () => {
|
||||
if (this.window === window) {
|
||||
this.window = undefined; this.surfaceParent = undefined
|
||||
this.finishConfirmation(false)
|
||||
if (!this.disposed) queueMicrotask(() => { this.sync() })
|
||||
}
|
||||
})
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
|
||||
window.webContents.on('render-process-gone', () => {
|
||||
if (!this.disposed) void window.loadURL(page).catch(() => {
|
||||
// A failed recovery keeps the parent blocked and leaves application exit available.
|
||||
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
|
||||
})
|
||||
})
|
||||
void window.loadURL(page).catch(() => {
|
||||
// The parent stays modal-blocked if its dedicated recovery document cannot load.
|
||||
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
|
||||
})
|
||||
window.webContents.on('will-redirect', (event) => { event.preventDefault() })
|
||||
const recovery = this.attachRecovery(window, parent)
|
||||
this.recovery = recovery
|
||||
void recovery.load()
|
||||
}
|
||||
this.window.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
}
|
||||
@@ -219,14 +230,15 @@ export class DesktopMandatoryUpdateWindow {
|
||||
if (parent?.isDestroyed()) return
|
||||
if (parent?.isMinimized()) parent.restore()
|
||||
parent?.show()
|
||||
if (this.embedded) parent?.focus()
|
||||
this.window?.show()
|
||||
parent?.focus()
|
||||
this.window?.focus()
|
||||
}
|
||||
|
||||
/** Detach IPC and release the modal during shutdown, including after the main window closes. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.recovery?.dispose()
|
||||
this.recovery = undefined
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
this.finishConfirmation(false)
|
||||
@@ -240,6 +252,93 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
this.window?.destroy()
|
||||
this.window = undefined
|
||||
this.surfaceParent = undefined
|
||||
}
|
||||
|
||||
private attachRecovery(surface: DesktopUpdateOverlay, parent: BrowserWindow): {
|
||||
load: () => Promise<void>
|
||||
dismiss: () => void
|
||||
dispose: () => void
|
||||
} {
|
||||
let released = false
|
||||
let failed = false
|
||||
let attempt = 0
|
||||
let prompt: AbortController | undefined
|
||||
const valid = (): boolean => !released && !this.disposed && this.options.policy().blocking
|
||||
&& this.options.parent() === parent && this.window === surface && !surface.isDestroyed() && !parent.isDestroyed()
|
||||
const dismiss = (): void => { prompt?.abort() }
|
||||
const show = (): void => {
|
||||
if (!valid() || !failed || prompt !== undefined || !parent.isVisible() || !parent.isFocused()) return
|
||||
const controller = new AbortController()
|
||||
prompt = controller
|
||||
const messages = this.options.locale.messages
|
||||
const policy = this.options.policy()
|
||||
const update = this.options.update()
|
||||
const destination = desktopPolicyPage(policy.page, this.options.allowedPageOrigins)
|
||||
const actions: readonly ('page' | 'quit')[] = destination === undefined ? ['quit'] : ['page', 'quit']
|
||||
const labels = { page: messages.mandatoryViewDownload, quit: messages.quit }
|
||||
const detail = [policy.detail ?? messages.mandatoryDetail,
|
||||
...('version' in update ? [messages.mandatoryVersion.replace('{version}', update.version)] : []),
|
||||
...(this.navigation?.page === 'failed' ? [messages.mandatoryViewPageFailed] : [])].join('\n\n')
|
||||
void dialog.showMessageBox(parent, { type: 'error', title: messages.mandatoryTitle,
|
||||
message: policy.title ?? messages.mandatoryTitle, detail,
|
||||
buttons: actions.map(action => labels[action]), defaultId: 0, cancelId: -1,
|
||||
noLink: true, signal: controller.signal }).then(({ response }) => {
|
||||
if (prompt === controller) prompt = undefined
|
||||
if (controller.signal.aborted) { show(); return }
|
||||
if (!valid() || !failed) return
|
||||
if (actions[response] === 'quit') app.quit()
|
||||
else if (actions[response] === 'page') {
|
||||
if (destination !== desktopPolicyPage(this.options.policy().page, this.options.allowedPageOrigins)) { show(); return }
|
||||
void this.navigate('page').then(() => { if (this.navigation?.page === 'failed') show() })
|
||||
.catch((error: unknown) => { console.error('desktop policy: recovery download page failed', error) })
|
||||
} else show()
|
||||
}).catch((error: unknown) => {
|
||||
if (prompt === controller) prompt = undefined
|
||||
console.error('desktop policy: native update recovery failed', error)
|
||||
})
|
||||
}
|
||||
const load = async (): Promise<void> => {
|
||||
if (!valid()) return
|
||||
const revision = ++attempt
|
||||
failed = false
|
||||
dismiss()
|
||||
try {
|
||||
await surface.loadURL(page)
|
||||
} catch (error) {
|
||||
if (!valid() || revision !== attempt) return
|
||||
console.error('desktop policy: mandatory update view failed to load', error)
|
||||
failed = true
|
||||
show()
|
||||
return
|
||||
}
|
||||
if (!valid() || revision !== attempt) return
|
||||
failed = false
|
||||
dismiss()
|
||||
this.sync()
|
||||
}
|
||||
const loaded = (): void => {
|
||||
if (!valid()) return
|
||||
failed = false
|
||||
dismiss()
|
||||
}
|
||||
const crashed = (): void => { void load() }
|
||||
const dispose = (): void => {
|
||||
if (released) return
|
||||
released = true
|
||||
dismiss()
|
||||
parent.off('focus', show)
|
||||
parent.off('hide', dismiss)
|
||||
surface.webContents.off('did-finish-load', loaded)
|
||||
surface.webContents.off('render-process-gone', crashed)
|
||||
surface.webContents.off('destroyed', dispose)
|
||||
}
|
||||
parent.on('focus', show)
|
||||
parent.on('hide', dismiss)
|
||||
surface.webContents.on('did-finish-load', loaded)
|
||||
surface.webContents.on('render-process-gone', crashed)
|
||||
surface.webContents.once('destroyed', dispose)
|
||||
return { load, dismiss, dispose }
|
||||
}
|
||||
|
||||
private view(): MandatoryUpdateView {
|
||||
|
||||
@@ -51,9 +51,10 @@ export class DesktopPlatformView {
|
||||
* @param preload - bundled sandboxed Platform preload path.
|
||||
* @param getLocale - current resolved Desktop language.
|
||||
* @param platform - operating system this shell runs on, reported to Platform.
|
||||
* @param blocked - Whether shell policy or an update prompt prevents opening a product view.
|
||||
*/
|
||||
constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale,
|
||||
private readonly platform: 'darwin' | 'win32') {}
|
||||
private readonly platform: 'darwin' | 'win32', private readonly blocked: (owner: PlatformOwner) => boolean) {}
|
||||
|
||||
/** @param next - private Host credentials; identity enrichment preserves an already open temporary document. */
|
||||
setSession(next: PlatformSession | null): void {
|
||||
@@ -75,9 +76,11 @@ export class DesktopPlatformView {
|
||||
* @param page - explicit supported Platform page.
|
||||
* @param bounds - owned renderer rectangle.
|
||||
* @returns when loading finishes, or without a document when superseded or the owner closes or navigates.
|
||||
* @throws When a shell update prompt blocks admission, including after storage cleanup.
|
||||
*/
|
||||
async open(owner: PlatformOwner, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise<void> {
|
||||
if (this.disposed) throw new Error('Platform view disposed')
|
||||
if (this.blocked(owner)) throw new Error('Platform view blocked by update prompt')
|
||||
this.close()
|
||||
const account = this.account
|
||||
if (account === null) throw new Error('Platform account unavailable')
|
||||
@@ -104,6 +107,7 @@ export class DesktopPlatformView {
|
||||
// Sanitize persisted authentication even after an unclean previous process exit.
|
||||
const failure = await this.cleanStorage(browserSession)
|
||||
if (generation !== this.generation) return
|
||||
if (this.blocked(owner)) { this.close(); throw new Error('Platform view blocked by update prompt') }
|
||||
if (failure !== null) {
|
||||
this.close()
|
||||
throw failure.error
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
|
||||
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
import type { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import type { DesktopUpdateOverlays, DesktopUpdateOverlay } from './update-overlay.ts'
|
||||
|
||||
/** Channels available only to the isolated update-dialog document. */
|
||||
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
|
||||
@@ -40,13 +40,17 @@ const page = 'dsh-app://shell/update-dialog.html'
|
||||
export class DesktopUpdateDialog {
|
||||
private disposed = false
|
||||
private revision = 0
|
||||
private window: BrowserWindow | undefined
|
||||
private view: DesktopUpdateOverlay | undefined
|
||||
private parent: BrowserWindow | undefined
|
||||
private closing: ReturnType<typeof setTimeout> | undefined
|
||||
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
|
||||
private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
|
||||
|
||||
/** Focus the current explanation or confirmation without replacing it or granting permission. */
|
||||
focus(): void { this.active?.window.focus() }
|
||||
focus(): void {
|
||||
if (this.active === undefined || this.parent?.isDestroyed() !== false) return
|
||||
this.parent.focus()
|
||||
this.view?.webContents.focus()
|
||||
}
|
||||
|
||||
/** Whether a shell prompt is awaiting a response. */
|
||||
get isOpen(): boolean { return this.active !== undefined }
|
||||
@@ -54,7 +58,7 @@ export class DesktopUpdateDialog {
|
||||
/**
|
||||
* @param preload - Bundled isolated preload.
|
||||
* @param locale - Shell-owned copy or a reader of the current UI language.
|
||||
* @param overlays - Application-owned overlay creation and input tracking.
|
||||
* @param overlays - Application-owned view creation and input tracking.
|
||||
*/
|
||||
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale),
|
||||
private readonly overlays: Pick<DesktopUpdateOverlays, 'create'>) {
|
||||
@@ -87,9 +91,9 @@ export class DesktopUpdateDialog {
|
||||
this.active?.finish(this.active.view.cancelId, true)
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
const existing = this.window
|
||||
const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
|
||||
this.window = window
|
||||
const existing = this.view
|
||||
const surface = existing ?? this.overlays.create(parent, this.preload)
|
||||
this.view = surface
|
||||
this.parent = parent
|
||||
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
|
||||
detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose,
|
||||
@@ -100,20 +104,23 @@ export class DesktopUpdateDialog {
|
||||
if (this.active?.view !== view) return
|
||||
this.active = undefined
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
if (!retain && !window.isDestroyed()) {
|
||||
window.webContents.send(UPDATE_DIALOG_IPC.changed, null)
|
||||
if (!retain && !surface.webContents.isDestroyed()) {
|
||||
surface.webContents.send(UPDATE_DIALOG_IPC.changed, null)
|
||||
this.closing = setTimeout(() => { this.close() }, 150)
|
||||
}
|
||||
resolve({ response, checkboxChecked: false })
|
||||
}
|
||||
this.active = { window, view, finish }
|
||||
this.active = { view, finish }
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
|
||||
const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } }
|
||||
window.once('closed', failed)
|
||||
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
|
||||
window.webContents.once('render-process-gone', failed)
|
||||
void window.loadURL(page).catch(failed)
|
||||
if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
|
||||
const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } }
|
||||
surface.webContents.once('destroyed', failed)
|
||||
surface.webContents.once('render-process-gone', failed)
|
||||
surface.webContents.once('preload-error', failed)
|
||||
surface.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
|
||||
surface.webContents.on('will-redirect', (event) => { event.preventDefault() })
|
||||
surface.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
void surface.loadURL(page).catch(failed)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,20 +140,16 @@ export class DesktopUpdateDialog {
|
||||
private close(): void {
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
const window = this.window
|
||||
this.window = undefined
|
||||
const surface = this.view
|
||||
this.view = undefined
|
||||
this.parent = undefined
|
||||
if (window !== undefined && !window.isDestroyed()) {
|
||||
// Release Windows modality before destruction so another application cannot become active.
|
||||
if (process.platform === 'win32' && window.isVisible()) window.hide()
|
||||
window.destroy()
|
||||
}
|
||||
surface?.destroy()
|
||||
}
|
||||
|
||||
private owned(event: IpcMainInvokeEvent): void {
|
||||
const window = this.window
|
||||
if (window === undefined || event.sender !== window.webContents
|
||||
|| event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) {
|
||||
const surface = this.view
|
||||
if (surface === undefined || event.sender !== surface.webContents
|
||||
|| event.senderFrame !== surface.webContents.mainFrame || event.senderFrame.url !== page) {
|
||||
throw new Error('desktop update: rejected unowned dialog renderer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,83 @@
|
||||
/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */
|
||||
import { BrowserWindow } from 'electron'
|
||||
/** Isolated update documents share their owner's native window and input lifetime. */
|
||||
import { WebContentsView, type BrowserWindow, type WebContents, type View } from 'electron'
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
|
||||
const unblockedInput = { revision: 0, blocked: false } as const
|
||||
/** Main-owned document with no independent native window or activation state. */
|
||||
export interface DesktopUpdateOverlay {
|
||||
readonly webContents: WebContents
|
||||
/** Focus the document only while its owner already has focus. */
|
||||
focus(): void
|
||||
/** Release the view, input interception, and owner listeners. */
|
||||
destroy(): void
|
||||
/** @returns Whether the document has been destroyed. */
|
||||
isDestroyed(): boolean
|
||||
/** @param url - Shell-owned document URL. @returns Completion of document loading. */
|
||||
loadURL(url: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Tracks application-owned update overlays and their parent input state. */
|
||||
type UpdateOwner = Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed' | 'isFocused'>
|
||||
& Pick<EventEmitter, 'on' | 'once' | 'off'> & {
|
||||
readonly webContents: Pick<EventEmitter, 'prependListener' | 'off'> & Pick<WebContents, 'focus'>
|
||||
readonly contentView: Pick<View, 'addChildView' | 'removeChildView'>
|
||||
}
|
||||
|
||||
/** Tracks isolated update views and each owner's keyboard-blocking revision. */
|
||||
export class DesktopUpdateOverlays {
|
||||
private readonly inputStates = new WeakMap<BrowserWindow, { revision: number; active: number; readonly blocked: boolean }>()
|
||||
private readonly inputs = new WeakMap<object, { revision: number; active: number; readonly blocked: boolean }>()
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose input may belong to an update dialog.
|
||||
* @returns Current blocking state; its revision changes whenever an overlay opens or closes.
|
||||
*/
|
||||
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
|
||||
return this.inputStates.get(parent) ?? unblockedInput
|
||||
/** @param parent - Product window. @returns Blocking state and attachment/release revision. */
|
||||
input(parent: Pick<BrowserWindow, 'isDestroyed'>): { readonly revision: number; readonly blocked: boolean } {
|
||||
const state = this.inputs.get(parent)
|
||||
return { revision: state?.revision ?? 0, blocked: state?.blocked ?? false }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose content is blocked while the overlay is open.
|
||||
* @param preload - Isolated shell-only preload.
|
||||
* @param title - Localized window title.
|
||||
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
|
||||
* @returns A transparent child that follows its parent's bounds and visibility after loading and releases its listeners on close.
|
||||
* @param parent - Native owner; its caption remains operable.
|
||||
* @param preload - Isolated shell preload.
|
||||
* @returns Document attached inside the owner, without showing or activating a native window.
|
||||
*/
|
||||
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
|
||||
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
|
||||
skipTaskbar: true, hasShadow: false, title,
|
||||
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
|
||||
})
|
||||
const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
|
||||
this.inputStates.set(parent, inputState)
|
||||
inputState.active++; inputState.revision++
|
||||
window.once('closed', () => { inputState.active--; inputState.revision++ })
|
||||
// macOS native modals animate the entire viewport as a sheet.
|
||||
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
|
||||
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
|
||||
if (!nativeModal && process.platform === 'darwin') {
|
||||
parent.on('focus', focus)
|
||||
// Shell dialogs block input before product shortcut listeners can dispatch it.
|
||||
parent.webContents.prependListener('before-input-event', blockInput)
|
||||
window.once('closed', () => {
|
||||
parent.off('focus', focus)
|
||||
if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput)
|
||||
})
|
||||
create(parent: UpdateOwner, preload: string): DesktopUpdateOverlay {
|
||||
const view = new WebContentsView({ webPreferences: {
|
||||
preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true,
|
||||
} })
|
||||
const ownerContents = parent.webContents
|
||||
const contents = view.webContents
|
||||
const input = this.inputs.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
|
||||
this.inputs.set(parent, input)
|
||||
input.active++; input.revision++
|
||||
const resize = (): void => {
|
||||
const { width, height } = parent.getContentBounds()
|
||||
const top = process.platform === 'win32' ? WINDOWS_TITLEBAR_HEIGHT : 0
|
||||
view.setBounds({ x: 0, y: top, width, height: Math.max(0, height - top) })
|
||||
}
|
||||
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
|
||||
parent.on('move', follow)
|
||||
parent.on('resize', follow)
|
||||
window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) })
|
||||
let ready = false
|
||||
const show = (): void => {
|
||||
if (ready && !window.isDestroyed() && !parent.isDestroyed() && parent.isVisible()) window.show()
|
||||
const focus = (): void => {
|
||||
if (!parent.isDestroyed() && parent.isFocused() && !contents.isDestroyed()) contents.focus()
|
||||
}
|
||||
parent.on('show', show)
|
||||
window.once('closed', () => { parent.off('show', show) })
|
||||
window.once('ready-to-show', () => { ready = true; show() })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
return window
|
||||
const block = (event: Electron.Event): void => { event.preventDefault(); focus() }
|
||||
const destroy = (): void => { if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false }) }
|
||||
const release = (): void => {
|
||||
parent.off('resize', resize)
|
||||
parent.off('focus', focus)
|
||||
parent.off('closed', destroy)
|
||||
ownerContents.off('before-input-event', block)
|
||||
input.active--; input.revision++
|
||||
if (!parent.isDestroyed()) {
|
||||
parent.contentView.removeChildView(view)
|
||||
if (parent.isFocused()) ownerContents.focus()
|
||||
}
|
||||
}
|
||||
view.setBackgroundColor('#00000000')
|
||||
parent.contentView.addChildView(view)
|
||||
resize()
|
||||
parent.on('resize', resize)
|
||||
parent.on('focus', focus)
|
||||
parent.once('closed', destroy)
|
||||
ownerContents.prependListener('before-input-event', block)
|
||||
contents.once('destroyed', release)
|
||||
contents.on('did-finish-load', focus)
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
return { webContents: contents, focus, destroy, isDestroyed: () => contents.isDestroyed(),
|
||||
loadURL: url => contents.loadURL(url) }
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,20 @@
|
||||
en: 94559f431b0b2151
|
||||
zh: 1a22166a2bc59686
|
||||
/desktop-local-update-verification/table-of-contents:
|
||||
en: a5edee063916cd27
|
||||
zh: 7a5286350ff09e58
|
||||
en: 86afdf78fa613876
|
||||
zh: 66fad143d99e999f
|
||||
/desktop-local-update-verification/update-dialog-themes:
|
||||
en: d086c3b38ce623f1
|
||||
zh: 8327c31092f5f174
|
||||
/desktop-local-update-verification/native-overlay-visibility:
|
||||
en: cff2c259a195bb57
|
||||
zh: fa03ff3fdc32a346
|
||||
en: 6f4de4503748f3e2
|
||||
zh: 648370375f16e463
|
||||
/desktop-local-update-verification/manual-walkthrough:
|
||||
en: c4b4ee8763d8464a
|
||||
zh: ee2159bc08966592
|
||||
/desktop-local-update-verification/evidence:
|
||||
en: 331a6709d0f7d03f
|
||||
zh: 98499192ef9d3a81
|
||||
en: dcb2473264c825d4
|
||||
zh: cebb2832efa15974
|
||||
/desktop-local-update-verification/open-verification:
|
||||
en: 20e9ea47acb1ff50
|
||||
zh: acce24b56d57e12c
|
||||
@@ -10,11 +10,24 @@ Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolu
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Update dialog themes](#verification-theme)
|
||||
- [Native overlay visibility](#verification-overlay)
|
||||
- [Evidence](#verification-evidence)
|
||||
- [Manual walkthrough](#verification-interactive)
|
||||
- [Open verification](#verification-open)
|
||||
|
||||
<a id="verification-theme"></a>
|
||||
|
||||
## Update dialog themes
|
||||
|
||||
After `pnpm --dir apps/desktop run build`, run the real Electron palette fixture on macOS:
|
||||
|
||||
```sh
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-theme.mjs
|
||||
```
|
||||
|
||||
The fixture creates a private profile, loads the production shell HTML, sandboxed preloads, renderer scripts, and bundled design tokens, and substitutes only fixed IPC status responses. Its 12 cases cover ordinary and mandatory documents starting in light/dark mode, switching while open, and following system appearance. It checks transparent document backgrounds, surface/text/button/mask colors, the code/address surface, the dark-only 1 CSS px inset outline, and the ordinary close icon, then writes four screenshots and `result.json` to `.desktop-build/qualification/update-theme-*`. It does not start a Host, download updates, run an installer, or qualify Windows native rendering. The first-close notice shares the ordinary document's CSS and theme script.
|
||||
|
||||
<a id="verification-overlay"></a>
|
||||
|
||||
## Native overlay visibility
|
||||
@@ -26,7 +39,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
|
||||
```
|
||||
|
||||
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It compares native visibility, unfiltered parent content, and listener cleanup with the owner-local expected output for parent hide/show and document readiness while the parent is hidden. It uses no network, product login, or dsh Host. This qualifies native window restoration rather than the full first-login flow.
|
||||
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It checks visible and hidden owners, absence of native child windows, retention of a separate control window’s focus after owner visibility notifications, and view/input cleanup including owner destruction. It uses no network, product login, or dsh Host. It qualifies the in-window carrier, not the full first-login or installation flow.
|
||||
|
||||
<a id="verification-interactive"></a>
|
||||
|
||||
@@ -49,8 +62,8 @@ Packaging supervision fixtures control their Git metadata and use real file hash
|
||||
| Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass |
|
||||
| Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass |
|
||||
| Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup |
|
||||
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
|
||||
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
|
||||
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; focused main-entry tests verify close-to-hide without quitting or stopping the Host, with policy retained |
|
||||
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, theme-resolved primary button, translucent backdrop without parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
|
||||
| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
|
||||
| Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking |
|
||||
| Host task protection | The actual controller with substituted composition detects running agents, queued turns/steps, and global and agent jobs; API reads do not warn. Admission locking returns 503 for new requests, drains existing requests, and rechecks tasks; unlock restores admission |
|
||||
@@ -63,7 +76,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil
|
||||
|
||||
The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written.
|
||||
|
||||
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
|
||||
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. The recorded Windows run passes ten scenarios, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. The macOS workspace path has not completed qualification; these Windows results do not cover it.
|
||||
|
||||
The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact.
|
||||
|
||||
|
||||
@@ -10,11 +10,24 @@
|
||||
|
||||
## 目录
|
||||
|
||||
- [更新弹窗主题](#verification-theme)
|
||||
- [原生浮层可见性](#verification-overlay)
|
||||
- [验证证据](#verification-evidence)
|
||||
- [手动演练](#verification-interactive)
|
||||
- [待验证事项](#verification-open)
|
||||
|
||||
<a id="verification-theme"></a>
|
||||
|
||||
## 更新弹窗主题
|
||||
|
||||
执行 `pnpm --dir apps/desktop run build` 后,在 macOS 上运行真实 Electron 配色夹具:
|
||||
|
||||
```sh
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-theme.mjs
|
||||
```
|
||||
|
||||
夹具创建独立 profile,加载生产壳 HTML、sandboxed preload、渲染脚本及打包的 design token,仅替换固定 IPC 状态响应。12 个用例覆盖普通和强更文档的浅色/暗色初始状态、打开时切换及跟随系统。它检查文档背景透明、卡片/文字/按钮/遮罩配色、代码/地址表面、仅暗色显示的 1 CSS px 内描边及普通弹窗关闭图标,并将四张截图和 `result.json` 写入 `.desktop-build/qualification/update-theme-*`。它不启动 Host、不下载更新、不运行安装器,也不验证 Windows 原生渲染。首次关闭提示共用普通文档的 CSS 和主题脚本。
|
||||
|
||||
<a id="verification-overlay"></a>
|
||||
|
||||
## 原生浮层可见性
|
||||
@@ -26,7 +39,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
|
||||
```
|
||||
|
||||
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它对照所属测试目录中的预期输出,检查父窗口隐藏/显示、父窗口隐藏期间文档就绪两种顺序中的原生可见性、父页面不受模糊影响及监听清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。
|
||||
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它检查可见和隐藏的父窗口、不创建原生子窗口、父窗口可见性通知后独立对照窗口仍保留焦点,以及销毁父窗口时的视图和输入清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖窗内承载方式,不覆盖完整的首次登录或安装流程。
|
||||
|
||||
<a id="verification-interactive"></a>
|
||||
|
||||
@@ -49,8 +62,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
|
||||
| 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 |
|
||||
| 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 |
|
||||
| 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 |
|
||||
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
|
||||
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
|
||||
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向主入口测试验证关闭时隐藏、不退出也不停止 Host,并保留策略 |
|
||||
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、随主题变化的主按钮、不模糊父页面的半透明遮罩、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
|
||||
| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
|
||||
| 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 |
|
||||
| Host 任务保护 | 替换组合环境后的真实控制器识别运行中的 agent、排队的 turn/step、全局和 agent job;API 读取不触发警告。请求准入锁定向新请求返回 503,等待已有请求结束并复查任务;解锁恢复准入 |
|
||||
@@ -63,7 +76,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p
|
||||
|
||||
[构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。
|
||||
|
||||
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
|
||||
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。已有 Windows 运行记录中十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。macOS 工作区路径尚未完成验证;这些 Windows 结果不覆盖它。
|
||||
|
||||
[Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
[
|
||||
{
|
||||
"initiallyVisible": true,
|
||||
"loaded": { "parent": true, "overlay": true, "filter": "none" },
|
||||
"restored": { "parent": true, "overlay": true, "filter": "none" },
|
||||
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
|
||||
},
|
||||
{
|
||||
"initiallyVisible": false,
|
||||
"loaded": { "parent": false, "overlay": false, "filter": "none" },
|
||||
"restored": { "parent": true, "overlay": true, "filter": "none" },
|
||||
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
|
||||
},
|
||||
{ "initiallyVisible": true, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
|
||||
{ "initiallyVisible": false, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
|
||||
{ "parentDestroyed": true, "overlayDestroyed": true }
|
||||
]
|
||||
+12
-9
@@ -17,6 +17,7 @@ import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../
|
||||
import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js'
|
||||
import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import { qualifyUpdateDialogs } from './update-dialogs.mjs'
|
||||
|
||||
const root = process.env.DSH_LOCAL_UPDATE_TEST_ROOT
|
||||
@@ -230,9 +231,9 @@ async function main() {
|
||||
protocol.handle('dsh-app', async request => {
|
||||
if (new URL(request.url).hostname === 'app') return new Response('<!doctype html><title>Updater qualification</title>', { headers: { 'content-type': 'text/html' } })
|
||||
const path = new URL(request.url).pathname.slice(1)
|
||||
assert.ok(['mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
|
||||
assert.ok(['design-platform.css', 'shell-theme.js', 'mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
|
||||
const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html'
|
||||
return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
return new Response(await readFile(new URL(`${path === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${path}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
})
|
||||
const parent = new BrowserWindow({ show: true, width: 900, height: 650, webPreferences: { preload: fileURLToPath(new URL('../../lib/preload-app.cjs', import.meta.url)), sandbox: true, contextIsolation: true } })
|
||||
await parent.loadURL('dsh-app://app/')
|
||||
@@ -244,6 +245,7 @@ async function main() {
|
||||
() => { modal?.sync() }, undefined,
|
||||
() => ({ version: '1.0.0', locale: 'zh-CN', timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }))
|
||||
modal = new DesktopMandatoryUpdateWindow({
|
||||
overlays: new DesktopUpdateOverlays(),
|
||||
preload: fileURLToPath(new URL('../../lib/preload-mandatory.cjs', import.meta.url)),
|
||||
locale: resolveDesktopLocale('zh-CN'), allowedPageOrigins: config.allowedPageOrigins,
|
||||
parent: () => parent, policy: () => policy.state, update: () => f.coordinator.state,
|
||||
@@ -268,11 +270,12 @@ async function main() {
|
||||
await policy.check('launch')
|
||||
const window = modal.confirmationWindow
|
||||
assert.ok(window)
|
||||
const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents
|
||||
const contents = process.platform === 'win32' ? await mandatoryFrame(parent)
|
||||
: parent.contentView.children.at(-1).webContents
|
||||
assert.equal(window.isModal(), false)
|
||||
assert.equal(parent.isEnabled(), true)
|
||||
window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
|
||||
window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
|
||||
contents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
|
||||
contents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
|
||||
async function until(expression) {
|
||||
try { return await contents.executeJavaScript(`new Promise((resolve, reject) => {
|
||||
const observer = new MutationObserver(check);
|
||||
@@ -325,7 +328,7 @@ async function main() {
|
||||
await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled")
|
||||
assert.equal(f.installations.length, 0)
|
||||
let capture
|
||||
try { capture = await window.webContents.capturePage() }
|
||||
try { capture = await contents.capturePage() }
|
||||
catch (error) { screenshot = { captured: false, reason: String(error) } }
|
||||
if (capture !== undefined) {
|
||||
await writeFile(join(root, 'mandatory-update.png'), capture.toPNG())
|
||||
@@ -348,15 +351,15 @@ async function main() {
|
||||
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
|
||||
}
|
||||
const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png'
|
||||
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
|
||||
await writeFile(join(root, name), (await contents.capturePage()).toPNG())
|
||||
dialogScreenshots.push(name)
|
||||
}
|
||||
assert.equal(policy.state.blocking, true)
|
||||
assert.equal(f.installations.length, 0)
|
||||
server.policy('clear')
|
||||
const closed = process.platform === 'win32' ? undefined : once(window, 'closed')
|
||||
const closed = process.platform === 'win32' ? undefined : once(contents, 'destroyed')
|
||||
await policy.check('manual', true)
|
||||
if (closed) { await closed; assert.equal(window.isDestroyed(), true) }
|
||||
if (closed) { await closed; assert.equal(contents.isDestroyed(), true) }
|
||||
else {
|
||||
const deadline = Date.now() + 10000
|
||||
while (!contents.detached && Date.now() < deadline) await delay(20)
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ async function run() {
|
||||
await app.whenReady()
|
||||
const { DesktopPlatformView } = await import(pathToFileURL(viewModule).href)
|
||||
const owner = new BrowserWindow({ show: false, width: 900, height: 700 })
|
||||
const manager = new DesktopPlatformView('', () => 'en_US', process.platform === 'win32' ? 'win32' : 'darwin')
|
||||
const manager = new DesktopPlatformView('', () => 'en_US', process.platform === 'win32' ? 'win32' : 'darwin', () => false)
|
||||
const account = userId => ({ origin: base, token: 'fixture-secret', userId })
|
||||
const states = []
|
||||
const record = async name => {
|
||||
|
||||
+19
-13
@@ -1,12 +1,12 @@
|
||||
/** Real isolated update dialogs; installer handoff is recorded by the caller's inert updater. */
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import assert from 'node:assert/strict'
|
||||
import { once } from 'node:events'
|
||||
import { access, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { app, BrowserWindow, protocol } from 'electron'
|
||||
import { app, BrowserWindow, nativeTheme, protocol } from 'electron'
|
||||
import { DesktopUpdateDialog } from '../../lib/types/update-dialog.js'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import { DesktopBackgroundNotice } from '../../lib/types/background-notice.js'
|
||||
import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js'
|
||||
|
||||
@@ -21,15 +21,17 @@ async function rendered(window) {
|
||||
|
||||
/** Exercise cancellation and explicit installation through the production preload and renderer. */
|
||||
export async function qualifyUpdateDialogs(root, fixture) {
|
||||
const theme = nativeTheme.themeSource
|
||||
nativeTheme.themeSource = 'light'
|
||||
const locale = resolveDesktopLocale('zh-CN')
|
||||
const messages = locale.messages
|
||||
const screenshots = []
|
||||
protocol.handle('dsh-app', async request => {
|
||||
const name = new URL(request.url).pathname.slice(1)
|
||||
assert.ok(['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name))
|
||||
assert.ok(['design-platform.css', 'shell-theme.js', 'update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name))
|
||||
const mime = name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css'
|
||||
: name.endsWith('.svg') ? 'image/svg+xml' : 'text/html'
|
||||
return new Response(await readFile(new URL(`../../renderer/${name}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
return new Response(await readFile(new URL(`${name === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${name}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
})
|
||||
const parent = new BrowserWindow({ show: true, width: 900, height: 650 })
|
||||
const dialogs = new DesktopUpdateDialog(fileURLToPath(new URL('../../lib/preload-update-dialog.cjs', import.meta.url)), locale, new DesktopUpdateOverlays())
|
||||
@@ -48,9 +50,10 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1,
|
||||
}
|
||||
f.restart(async () => (await dialogs.show(parent, options)).response === 0)
|
||||
const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const created = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const pending = f.coordinator.install(available.version)
|
||||
const [, window] = await created
|
||||
const [, contents] = await created
|
||||
const window = { webContents: contents, isDestroyed: () => contents.isDestroyed() }
|
||||
await once(window.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
|
||||
await rendered(window)
|
||||
const view = await window.webContents.executeJavaScript(`({
|
||||
@@ -76,7 +79,7 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
const name = active ? 'update-active-tasks.png' : 'update-ready.png'
|
||||
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
|
||||
screenshots.push(name)
|
||||
const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) })
|
||||
const closed = once(window.webContents, 'destroyed', { signal: AbortSignal.timeout(10_000) })
|
||||
if (active) {
|
||||
await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined")
|
||||
} else {
|
||||
@@ -89,11 +92,12 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
assert.equal(f.installations.length, active ? 1 : 0)
|
||||
}
|
||||
assert.deepEqual(f.installations, [[true, true]])
|
||||
const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const created = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const diagnostics = 'exit 0; shutdown acknowledged false\n' + 'at internal/diagnostic/path\n'.repeat(1000)
|
||||
const pending = dialogs.show(parent, { title: messages.updateFailedTitle, message: messages.updateStopFailed,
|
||||
technicalDetails: diagnostics })
|
||||
const [, errorWindow] = await created
|
||||
const [, errorContents] = await created
|
||||
const errorWindow = { webContents: errorContents }
|
||||
await once(errorWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
|
||||
await rendered(errorWindow)
|
||||
assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details').open"), false)
|
||||
@@ -116,17 +120,18 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
errorWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
assert.equal((await pending).response, 0)
|
||||
dialogs.cancel()
|
||||
await once(errorWindow, 'closed', { signal: AbortSignal.timeout(10_000) })
|
||||
await once(errorWindow.webContents, 'destroyed', { signal: AbortSignal.timeout(10_000) })
|
||||
const markerPath = join(root, 'background-close-confirmed')
|
||||
const notice = new DesktopBackgroundNotice({ markerPath, locale: () => locale,
|
||||
show: options => dialogs.show(parent, options), focus: () => dialogs.focus() })
|
||||
try {
|
||||
for (const confirm of [false, true]) {
|
||||
parent.show()
|
||||
const createdNotice = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const createdNotice = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const hidden = Promise.withResolvers()
|
||||
notice.close(() => { parent.hide(); hidden.resolve() })
|
||||
const [, noticeWindow] = await createdNotice
|
||||
const [, noticeContents] = await createdNotice
|
||||
const noticeWindow = { webContents: noticeContents }
|
||||
await once(noticeWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
|
||||
await rendered(noticeWindow)
|
||||
assert.equal(parent.isVisible(), true)
|
||||
@@ -136,7 +141,7 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
detailHidden: document.getElementById('detail').hidden,
|
||||
})`), JSON.parse(await readFile(new URL('../expected/background-close-confirmation.json', import.meta.url), 'utf8')))
|
||||
await assert.rejects(access(markerPath))
|
||||
const closedNotice = once(noticeWindow, 'closed', { signal: AbortSignal.timeout(10_000) })
|
||||
const closedNotice = once(noticeWindow.webContents, 'destroyed', { signal: AbortSignal.timeout(10_000) })
|
||||
if (confirm) {
|
||||
const name = 'background-close-confirmation.png'
|
||||
await noticeWindow.webContents.executeJavaScript(`Promise.all(document.getAnimations().map(animation => animation.finished))`)
|
||||
@@ -159,5 +164,6 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
dialogs.dispose()
|
||||
parent.destroy()
|
||||
protocol.unhandle('dsh-app')
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
}
|
||||
+37
-72
@@ -1,4 +1,4 @@
|
||||
/** Native overlay visibility qualification without a Host, network, or user profile. */
|
||||
/** Native-owner visibility and focus qualification for isolated in-window update views. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { once } from 'node:events'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
@@ -7,88 +7,53 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
|
||||
const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url))
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const root = mkdtempSync(join(directory, 'update-overlay-'))
|
||||
mkdirSync(join(root, 'profile'))
|
||||
app.setPath('userData', join(root, 'profile'))
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
async function main() {
|
||||
await app.whenReady()
|
||||
const cases = []
|
||||
const windows = []
|
||||
const closeWindows = () => {
|
||||
for (const window of [...windows].reverse()) if (!window.isDestroyed()) window.destroy()
|
||||
}
|
||||
let phase = 'application ready'
|
||||
const deadline = setTimeout(() => {
|
||||
console.error(`Update overlay qualification exceeded 30 seconds: ${phase}`)
|
||||
closeWindows()
|
||||
app.exit(1)
|
||||
}, 30_000)
|
||||
|
||||
async function qualify(initiallyVisible, closeParentFirst = false) {
|
||||
const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 })
|
||||
windows.push(parent)
|
||||
phase = `parent load (${initiallyVisible})`
|
||||
await parent.loadURL('data:text/html,<h1>Update overlay visibility</h1><input aria-label="Keyboard probe">')
|
||||
const initialShowListeners = parent.listenerCount('show')
|
||||
const overlay = new DesktopUpdateOverlays().create(parent, undefined, 'Overlay qualification', false)
|
||||
windows.push(overlay)
|
||||
const ready = once(overlay, 'ready-to-show')
|
||||
const initiallyShown = initiallyVisible ? once(overlay, 'show') : undefined
|
||||
phase = `overlay load (${initiallyVisible})`
|
||||
await overlay.loadURL('data:text/html,<h1>Update confirmation</h1>')
|
||||
phase = `overlay ready (${initiallyVisible})`
|
||||
await ready
|
||||
await initiallyShown
|
||||
const filter = () => parent.webContents.executeJavaScript('getComputedStyle(document.body).filter')
|
||||
const loaded = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: await filter() }
|
||||
if (initiallyVisible) {
|
||||
const hidden = once(parent, 'hide')
|
||||
parent.hide()
|
||||
await hidden
|
||||
}
|
||||
const shown = once(parent, 'show')
|
||||
phase = `overlay restored (${initiallyVisible})`
|
||||
parent.show()
|
||||
await shown
|
||||
const restoredFilter = await filter()
|
||||
const restored = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: restoredFilter }
|
||||
phase = `overlay release (${initiallyVisible})`
|
||||
if (closeParentFirst) {
|
||||
parent.destroy()
|
||||
if (!overlay.isDestroyed()) overlay.destroy()
|
||||
cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() })
|
||||
return
|
||||
}
|
||||
overlay.destroy()
|
||||
const released = { filter: await filter(), showListenersRestored: parent.listenerCount('show') === initialShowListeners,
|
||||
keyboardListeners: parent.webContents.listenerCount('before-input-event') }
|
||||
parent.hide()
|
||||
parent.show()
|
||||
cases.push({ initiallyVisible, loaded, restored, released })
|
||||
parent.destroy()
|
||||
}
|
||||
|
||||
const deadline = setTimeout(() => { console.error('Overlay qualification deadline exceeded'); app.exit(1) }, 30000)
|
||||
try {
|
||||
await app.whenReady()
|
||||
await qualify(true)
|
||||
await qualify(false)
|
||||
await qualify(true, true)
|
||||
await writeFile(join(root, 'result.json'), `${JSON.stringify(cases, null, 2)}\n`)
|
||||
const expected = JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8'))
|
||||
assert.deepEqual(cases, expected)
|
||||
console.log(`Update overlay qualification passed: ${root}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
for (const initiallyVisible of [true, false]) {
|
||||
const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 })
|
||||
windows.push(parent)
|
||||
await parent.loadURL('data:text/html,<h1>Update owner</h1>')
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const overlay = overlays.create(parent, undefined)
|
||||
await overlay.loadURL('data:text/html,<h1>Isolated update</h1>')
|
||||
assert.equal(parent.getChildWindows().length, 0)
|
||||
assert.equal(parent.contentView.children.length, 1)
|
||||
const control = new BrowserWindow({ show: true, width: 300, height: 200 })
|
||||
windows.push(control)
|
||||
await control.loadURL('data:text/html,<h1>Focus control</h1>')
|
||||
const focused = control.isFocused() ? undefined : once(control, 'focus')
|
||||
control.focus(); await focused
|
||||
parent.emit('show')
|
||||
assert.equal(control.isFocused(), true)
|
||||
assert.equal(parent.isFocused(), false)
|
||||
const destroyed = once(overlay.webContents, 'destroyed')
|
||||
overlay.destroy(); await destroyed
|
||||
cases.push({ initiallyVisible, childWindows: parent.getChildWindows().length, controlKeepsFocus: control.isFocused(),
|
||||
viewsReleased: parent.contentView.children.length === 0, input: overlays.input(parent) })
|
||||
parent.destroy(); control.destroy()
|
||||
}
|
||||
const parent = new BrowserWindow({ show: false })
|
||||
windows.push(parent)
|
||||
const overlay = new DesktopUpdateOverlays().create(parent, undefined)
|
||||
const destroyed = once(overlay.webContents, 'destroyed')
|
||||
parent.destroy(); await destroyed
|
||||
cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() })
|
||||
assert.deepEqual(cases, JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8')))
|
||||
await writeFile(join(root, 'result.json'), JSON.stringify(cases, null, 2) + '\n')
|
||||
console.log(`Update view qualification passed: ${root}`)
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
closeWindows()
|
||||
app.exit(process.exitCode ?? 0)
|
||||
for (const window of windows) if (!window.isDestroyed()) window.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch(error => { console.error(error); app.exit(1) })
|
||||
void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/** Real Chromium qualification of shared shell tokens and app-resolved palette changes. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, protocol } from 'electron'
|
||||
import { resolveDesktopLocale } from '../../lib/types/locale.js'
|
||||
import { UPDATE_DIALOG_IPC } from '../../lib/types/update-dialog.js'
|
||||
import { MANDATORY_IPC } from '../../lib/types/mandatory-update-ipc.js'
|
||||
|
||||
const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url))
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const root = mkdtempSync(join(directory, 'update-theme-'))
|
||||
app.setPath('userData', root)
|
||||
protocol.registerSchemesAsPrivileged([{ scheme: 'dsh-app', privileges: { standard: true, secure: true, supportFetchAPI: true } }])
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
async function main() {
|
||||
await app.whenReady()
|
||||
const original = nativeTheme.themeSource
|
||||
const locale = resolveDesktopLocale('en')
|
||||
const results = []
|
||||
const windows = []
|
||||
const deadline = setTimeout(() => { console.error('Theme qualification deadline exceeded'); app.exit(1) }, 30000)
|
||||
protocol.handle('dsh-app', async request => {
|
||||
const name = new URL(request.url).pathname.slice(1)
|
||||
const file = new URL(`${name === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${name}`, import.meta.url)
|
||||
const mime = name.endsWith('.css') ? 'text/css' : name.endsWith('.js') ? 'text/javascript'
|
||||
: name.endsWith('.svg') ? 'image/svg+xml' : 'text/html'
|
||||
return new Response(await readFile(file), { headers: { 'content-type': mime } })
|
||||
})
|
||||
ipcMain.handle(UPDATE_DIALOG_IPC.status, () => ({ revision: 1, locale: locale.id,
|
||||
title: 'Check for updates', message: 'Update ready', detail: 'Shell palette qualification',
|
||||
buttons: ['Install', 'Later'], cancelId: 1, closeLabel: 'Close', technicalDetails: 'Details', technicalDetailsLabel: 'Details' }))
|
||||
ipcMain.handle(MANDATORY_IPC.status, () => ({ locale, policy: { blocking: true, checking: false },
|
||||
update: { phase: 'error', failedOperation: 'download', version: '1.0.0', technicalDetails: 'Details' }, deferred: false }))
|
||||
try {
|
||||
assert.equal(await readFile(new URL('../../lib/welcome/design-platform.css', import.meta.url), 'utf8'),
|
||||
await readFile(new URL('../../../../packages/client/ui-theme/src/styles/design-platform.css', import.meta.url), 'utf8'))
|
||||
for (const document of ['update-dialog', 'mandatory-update']) {
|
||||
for (const initial of ['light', 'dark']) {
|
||||
nativeTheme.themeSource = initial
|
||||
const window = new BrowserWindow({ show: true, width: 600, height: 650,
|
||||
webPreferences: { preload: fileURLToPath(new URL(`../../lib/preload-${document === 'update-dialog' ? 'update-dialog' : 'mandatory'}.cjs`, import.meta.url)),
|
||||
contextIsolation: true, sandbox: true, nodeIntegration: false } })
|
||||
windows.push(window)
|
||||
// Palette checks exclude the user's pointer so hover cannot select an interaction color.
|
||||
window.setIgnoreMouseEvents(true)
|
||||
await window.loadURL(`dsh-app://shell/${document}.html`)
|
||||
for (const source of [initial, initial === 'light' ? 'dark' : 'light', 'system']) {
|
||||
nativeTheme.themeSource = source
|
||||
const dark = nativeTheme.shouldUseDarkColors
|
||||
const colors = await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
|
||||
const dark = ${dark};
|
||||
const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Palette did not settle')); }, 10000);
|
||||
const observer = new MutationObserver(check);
|
||||
function check() {
|
||||
if (!document.body.classList.contains('visible') || document.body.hasAttribute('data-ds-dark-theme') !== dark) return;
|
||||
observer.disconnect(); clearTimeout(deadline);
|
||||
const card = getComputedStyle(document.querySelector('main'));
|
||||
const primary = getComputedStyle(document.querySelector('.primary'));
|
||||
resolve({ dark, surface: card.backgroundColor, label: card.color, primary: primary.backgroundColor,
|
||||
primaryLabel: primary.color, boxShadow: card.boxShadow,
|
||||
diagnostic: getComputedStyle(document.querySelector('pre, #address')).backgroundColor, body: getComputedStyle(document.body).backgroundColor,
|
||||
mask: getComputedStyle(document.body, '::before').backgroundColor,
|
||||
close: document.querySelector('.close') ? getComputedStyle(document.querySelector('.close'), '::before').backgroundColor : null });
|
||||
}
|
||||
observer.observe(document.body, { attributes: true }); check();
|
||||
})`)
|
||||
const { boxShadow, ...palette } = colors
|
||||
if (dark) assert.match(boxShadow, /0px 0px 0px 1px inset$/)
|
||||
else assert.equal(boxShadow, 'none')
|
||||
assert.deepEqual(palette, { dark, surface: dark ? 'rgb(35, 35, 36)' : 'rgb(255, 255, 255)',
|
||||
label: dark ? 'rgb(249, 250, 251)' : 'rgb(15, 17, 21)',
|
||||
primary: dark ? 'rgb(249, 250, 251)' : 'rgb(15, 17, 21)',
|
||||
primaryLabel: dark ? 'rgb(53, 54, 56)' : 'rgb(255, 255, 255)',
|
||||
diagnostic: dark ? 'rgb(27, 27, 28)' : 'rgb(249, 250, 251)', body: 'rgba(0, 0, 0, 0)',
|
||||
mask: dark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.24)',
|
||||
close: document === 'update-dialog' ? dark ? 'rgb(249, 250, 251)' : 'rgb(15, 17, 21)' : null })
|
||||
results.push({ document, initial, source, ...colors })
|
||||
if (source === initial) {
|
||||
await window.webContents.executeJavaScript('Promise.all(document.getAnimations().map(animation => animation.finished))')
|
||||
await writeFile(join(root, `${document}-${initial}.png`), (await window.webContents.capturePage()).toPNG())
|
||||
}
|
||||
}
|
||||
window.destroy()
|
||||
}
|
||||
}
|
||||
await writeFile(join(root, 'result.json'), JSON.stringify(results, null, 2) + '\n')
|
||||
console.log(`Update theme qualification passed (${results.length} cases): ${root}`)
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
for (const window of windows) if (!window.isDestroyed()) window.destroy()
|
||||
ipcMain.removeHandler(UPDATE_DIALOG_IPC.status)
|
||||
ipcMain.removeHandler(MANDATORY_IPC.status)
|
||||
protocol.unhandle('dsh-app')
|
||||
nativeTheme.themeSource = original
|
||||
}
|
||||
}
|
||||
void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||
+18
-5
@@ -6,7 +6,7 @@ import { registerHooks } from 'node:module'
|
||||
import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { app, BrowserWindow, Menu } from 'electron'
|
||||
import { app, BrowserWindow, Menu, webContents } from 'electron'
|
||||
import updaterModule from 'electron-updater'
|
||||
import { createUpdateServer } from './update-server.mjs'
|
||||
import { fixture } from './workspace-update-adapters.mjs'
|
||||
@@ -100,6 +100,11 @@ async function windowAt(url) {
|
||||
if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
|
||||
return mandatoryFrameDriver(await windowAt('dsh-app://app/'))
|
||||
}
|
||||
if (process.platform !== 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
|
||||
let contents
|
||||
await waitFor(() => { contents = webContents.getAllWebContents().find(candidate => candidate.getURL() === url); return contents !== undefined }, 'mandatory view document')
|
||||
return { webContents: contents, isDestroyed: () => contents.isDestroyed() }
|
||||
}
|
||||
const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url)
|
||||
if (existing) return existing
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -161,11 +166,19 @@ async function clickText(window, label) {
|
||||
await waitFor(async () => window.isDestroyed()
|
||||
|| await window.webContents.executeJavaScript("document.getElementById('title').textContent") !== title, `dialog response: ${label}`)
|
||||
}
|
||||
const dialogDrivers = new WeakMap()
|
||||
async function dialogWith(message) {
|
||||
let found
|
||||
await waitFor(async () => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.webContents.getURL() !== 'dsh-app://shell/update-dialog.html') continue
|
||||
for (const contents of webContents.getAllWebContents()) {
|
||||
if (contents.getURL() !== 'dsh-app://shell/update-dialog.html') continue
|
||||
let window = dialogDrivers.get(contents)
|
||||
if (!window) {
|
||||
const owner = BrowserWindow.fromWebContents(contents)
|
||||
window = { webContents: contents, isDestroyed: () => contents.isDestroyed(),
|
||||
isVisible: () => owner?.isVisible() ?? false, isFocused: () => contents.isFocused(), isEnabled: () => owner?.isEnabled() ?? true }
|
||||
dialogDrivers.set(contents, window)
|
||||
}
|
||||
try {
|
||||
const ready = await window.webContents.executeJavaScript(`document.getElementById('title')?.textContent === ${JSON.stringify(message)} && !!document.querySelector('#actions button')`)
|
||||
if (ready) { found = window; return true }
|
||||
@@ -249,7 +262,7 @@ async function qualify() {
|
||||
await press(mainWindow, `document.querySelector('button[data-error="true"]')`)
|
||||
await server.arrived()
|
||||
await documentReady(mainWindow, `document.querySelector('button[aria-disabled="true"]')`)
|
||||
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
assert.equal(webContents.getAllWebContents().some(contents => contents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
await screenshot(mainWindow, 'downloading.png')
|
||||
server.release()
|
||||
const confirmation = desktopUpdateReadyConfirmation(messages, '0.1.6-nightly.1', process.platform)
|
||||
@@ -353,7 +366,7 @@ async function qualify() {
|
||||
await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window for recovery')
|
||||
await press(forcedRecovery, `document.getElementById('update')`)
|
||||
await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`)
|
||||
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
assert.equal(webContents.getAllWebContents().some(contents => contents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
const forcedHost = fixture.host
|
||||
await control('hold-shutdown')
|
||||
await press(forcedRecovery, `document.getElementById('update')`)
|
||||
|
||||
@@ -35,6 +35,8 @@ async function fixture<T>(body: (manifest: string, source: string) => Promise<T>
|
||||
await prepareInstalledUpdateBootstrap(manifest)
|
||||
const source = join(root, 'app')
|
||||
await mkdir(join(source, 'lib'), { recursive: true })
|
||||
await mkdir(join(source, 'lib/welcome'), { recursive: true })
|
||||
await writeFile(join(source, 'lib/welcome/design-platform.css'), 'body { --fixture-token: white; }\n')
|
||||
await mkdir(join(source, 'renderer'))
|
||||
for (const file of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
|
||||
await writeFile(join(source, 'lib', file), '// inert fixture\n')
|
||||
@@ -57,19 +59,23 @@ describe('installed-update application inputs and builder configuration', () =>
|
||||
.toMatchObject({ dependenciesFrozen: false, signed: false, bootTested: false })
|
||||
const directory = await verifyInstalledUpdateApplication(join(manifest, '..'))
|
||||
expect((await readdir(directory)).sort()).toEqual(['installed-update-identity.mjs', 'lib', 'qualification-bootstrap.mjs', 'renderer'])
|
||||
expect(await readFile(join(directory, 'lib/welcome/design-platform.css'), 'utf8')).toBe('body { --fixture-token: white; }\n')
|
||||
await writeFile(join(source, 'lib/main.js'), '// later checkout\n')
|
||||
expect(await readFile(join(directory, 'lib/main.js'), 'utf8')).toBe('// inert fixture\n')
|
||||
await expect(prepareInstalledUpdateApplication(manifest, source)).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['modified', 'added', 'missing'] as const)('rejects %s frozen application bytes', async (failure) => {
|
||||
it.each([
|
||||
['modified', 'lib/main.js'], ['added', 'lib/unrecorded.js'], ['missing', 'lib/main.js'],
|
||||
['modified', 'lib/welcome/design-platform.css'], ['missing', 'lib/welcome/design-platform.css'],
|
||||
] as const)('rejects %s frozen application bytes in %s', async (failure, file) => {
|
||||
await fixture(async (manifest, source) => {
|
||||
await prepareInstalledUpdateApplication(manifest, source)
|
||||
const root = join(manifest, '..')
|
||||
const directory = await verifyInstalledUpdateApplication(root)
|
||||
if (failure === 'missing') await rm(join(directory, 'lib/main.js'))
|
||||
else await writeFile(join(directory, failure === 'added' ? 'lib/unrecorded.js' : 'lib/main.js'), '// invalid\n')
|
||||
if (failure === 'missing') await rm(join(directory, file))
|
||||
else await writeFile(join(directory, file), '// invalid\n')
|
||||
await expect(verifyInstalledUpdateApplication(root)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,8 @@ async function fixture(body: (context: {
|
||||
await prepareInstalledUpdateBootstrap(manifest)
|
||||
const source = join(root, 'source')
|
||||
await mkdir(join(source, 'lib'), { recursive: true })
|
||||
await mkdir(join(source, 'lib/welcome'), { recursive: true })
|
||||
await writeFile(join(source, 'lib/welcome/design-platform.css'), 'body { --fixture-token: white; }\n')
|
||||
await mkdir(join(source, 'renderer'))
|
||||
for (const name of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
|
||||
await writeFile(join(source, 'lib', name), '// inert fixture\n')
|
||||
@@ -104,7 +106,7 @@ describe('installed update archive contents', () => {
|
||||
it.each(versions)('reads real ASAR and runtime inventories for %s without claiming installation', async (version) => {
|
||||
await fixture(async ({ manifest, payload }) => {
|
||||
expect(await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).toMatchObject({
|
||||
version, applicationFiles: 7, dependenciesFrozen: false, installed: false,
|
||||
version, applicationFiles: 8, dependenciesFrozen: false, installed: false,
|
||||
resignedExecutables: [join(payload, 'resources/app.asar.unpacked/dsh/tool.exe')],
|
||||
})
|
||||
}, version)
|
||||
|
||||
@@ -18,17 +18,21 @@ const overlays = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class Window extends EventEmitter {
|
||||
private destroyed = false
|
||||
readonly webContents = { setWindowOpenHandler: vi.fn() }
|
||||
readonly webContents = Object.assign(new EventEmitter(), { setWindowOpenHandler: vi.fn(),
|
||||
focus: vi.fn(), isDestroyed: () => this.destroyed, loadURL: vi.fn(async () => {}),
|
||||
close: () => { this.destroyed = true; this.webContents.emit('destroyed') },
|
||||
})
|
||||
readonly focus = vi.fn()
|
||||
readonly show = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
readonly setBackgroundColor = vi.fn()
|
||||
setMenu() {}
|
||||
isDestroyed() { return this.destroyed }
|
||||
destroy() { this.destroyed = true; this.emit('closed') }
|
||||
destroy() { this.destroyed = true; this.webContents.emit('destroyed') }
|
||||
}
|
||||
return { Window }
|
||||
})
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, WebContentsView: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() },
|
||||
}) } }))
|
||||
@@ -89,7 +93,7 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
|
||||
let current: WindowFixture | undefined = window
|
||||
const updateMenu = vi.fn()
|
||||
const updateOverlays = new DesktopUpdateOverlays()
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow))
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window))
|
||||
keyboard.attach(current)
|
||||
onTestFinished(() => { keyboard.dispose() })
|
||||
const handlers = new Map<string, (event: InvokeFixture, ...args: unknown[]) => unknown>(
|
||||
@@ -108,12 +112,12 @@ function updateOverlayFixture(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
onTestFinished(() => { platform.mockRestore() })
|
||||
const parent: WindowFixture & Pick<BrowserWindow, 'getContentBounds'> = Object.assign(f.window, {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }),
|
||||
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
|
||||
})
|
||||
Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) })
|
||||
return () => {
|
||||
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false)
|
||||
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload')
|
||||
onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() })
|
||||
expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled()
|
||||
return overlay
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const harness = await vi.hoisted(async () => {
|
||||
const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() })
|
||||
class FakeWindow extends EventEmitter {
|
||||
destroyed = false
|
||||
readonly contentView = { addChildView: vi.fn(), removeChildView: vi.fn() }
|
||||
readonly urls: string[] = []
|
||||
readonly webContents = Object.assign(new EventEmitter(), {
|
||||
id: 42,
|
||||
@@ -129,6 +130,18 @@ const harness = await vi.hoisted(async () => {
|
||||
if (event.preventDefault.mock.calls.length === 0) this.destroy()
|
||||
}
|
||||
}
|
||||
const views: FakeView[] = []
|
||||
class FakeView extends FakeWindow {
|
||||
constructor() {
|
||||
super({ show: false })
|
||||
windows.pop()
|
||||
views.push(this)
|
||||
Object.assign(this.webContents, {
|
||||
loadURL: (url: string) => this.loadURL(url),
|
||||
close: () => { this.destroy(); this.webContents.emit('destroyed') },
|
||||
})
|
||||
}
|
||||
}
|
||||
class FakeHost {
|
||||
readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false)
|
||||
readonly inspectQuit = vi.fn(async () => ({ activeTasks: false, scheduledTasks: false }))
|
||||
@@ -191,7 +204,7 @@ const harness = await vi.hoisted(async () => {
|
||||
const shellDialog = { isOpen: false, focus: vi.fn() }
|
||||
return {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
windows, views, hosts, handlers, app, FakeWindow, FakeView, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
@@ -238,7 +251,8 @@ const harness = await vi.hoisted(async () => {
|
||||
set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
|
||||
reset() {
|
||||
accountListener = undefined
|
||||
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
||||
windows.length = 0
|
||||
views.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
||||
trays.length = 0
|
||||
backgroundNotice.markerPath = undefined
|
||||
shellDialog.isOpen = false
|
||||
@@ -277,6 +291,7 @@ vi.mock('../src/policy-test-auth.ts', () => ({ DesktopPolicyTestAuth: class {
|
||||
vi.mock('electron', () => ({
|
||||
app: harness.app,
|
||||
BrowserWindow: harness.FakeWindow,
|
||||
WebContentsView: harness.FakeView,
|
||||
dialog: harness.dialog,
|
||||
shell: { openExternal: harness.openExternal },
|
||||
nativeTheme: harness.nativeTheme,
|
||||
@@ -330,6 +345,7 @@ vi.mock('../src/host-process.ts', async importOriginal => ({
|
||||
}))
|
||||
vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class {
|
||||
get isOpen() { return harness.shellDialog.isOpen }
|
||||
input() { return { revision: 0, blocked: false } }
|
||||
show(owner: { options: { modal?: boolean } }, options: unknown) {
|
||||
return (owner.options.modal ? harness.dialog.showMessageBox(owner, options)
|
||||
: harness.dialog.showMessageBox(options)) as Promise<Electron.MessageBoxReturnValue>
|
||||
@@ -485,7 +501,7 @@ describe('desktop main startup', () => {
|
||||
for (const [name, mime] of [
|
||||
['update-dialog.html', 'text/html'], ['update-dialog.css', 'text/css'], ['update-dialog.js', 'text/javascript'],
|
||||
['mandatory-update.html', 'text/html'], ['mandatory-update.css', 'text/css'], ['mandatory-update.js', 'text/javascript'],
|
||||
['update-close.svg', 'image/svg+xml'],
|
||||
['update-close.svg', 'image/svg+xml'], ['shell-theme.js', 'text/javascript'],
|
||||
] as const) {
|
||||
const response = await handler(new Request(`dsh-app://shell/${name}`))
|
||||
expect(response.status).toBe(200)
|
||||
@@ -514,6 +530,9 @@ describe('desktop main startup', () => {
|
||||
await handler(request)
|
||||
expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer'))
|
||||
}
|
||||
const tokens = new Request('dsh-app://shell/design-platform.css')
|
||||
await handler(tokens)
|
||||
expect(serveWebDocument).toHaveBeenLastCalledWith(tokens, join('desktop-test-app', 'lib/welcome'))
|
||||
expect(forwardWebRequest).not.toHaveBeenCalled()
|
||||
expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404)
|
||||
})
|
||||
@@ -1280,6 +1299,27 @@ describe('desktop main startup', () => {
|
||||
expect(harness.trays[0]!.setContextMenu.mock.calls.length).toBe(relabels + 1)
|
||||
})
|
||||
|
||||
it('hides a macOS owner with a mandatory view without quitting or stopping the Host', async () => {
|
||||
vi.stubGlobal('process', { ...process, platform: 'darwin' })
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
|
||||
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
|
||||
} })))
|
||||
const host = await readyWorkspace()
|
||||
await harness.policyBlocked.promise
|
||||
const window = harness.windows[0]!
|
||||
const hides = window.hide.mock.calls.length
|
||||
window.close()
|
||||
expect(window.hide).toHaveBeenCalledTimes(hides + 1)
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(harness.app.quit).not.toHaveBeenCalled()
|
||||
expect(harness.backgroundNotice.close).not.toHaveBeenCalled()
|
||||
expect(host.inspectQuit).not.toHaveBeenCalled()
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
harness.app.emit('activate')
|
||||
expect(window.show).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the workspace visible while acknowledgement is pending and ignores a destroyed window', async () => {
|
||||
await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
@@ -1797,7 +1837,7 @@ describe('desktop main startup', () => {
|
||||
const host = await readyForUpdate()
|
||||
if (mandatory) await harness.policyBlocked.promise
|
||||
const window = harness.windows[0]!
|
||||
const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
|
||||
const dialogOwner = mandatory ? harness.views.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
|
||||
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
|
||||
window.fullscreen = true
|
||||
window.setFullScreen.mockImplementation(() => {})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
|
||||
import type { BrowserWindow, MessageBoxOptions, WebContents, WebFrameMain } from 'electron'
|
||||
import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts'
|
||||
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
|
||||
import { resolveDesktopLocale } from '../src/locale.ts'
|
||||
@@ -8,14 +8,16 @@ import type { DesktopPolicyState } from '../src/mandatory-update-policy.ts'
|
||||
import type { DesktopUpdateState } from '../src/ipc.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ handlers: new Map<string, (...args: unknown[]) => unknown>(),
|
||||
open: vi.fn<(...args: unknown[]) => Promise<void>>(), write: vi.fn(), read: vi.fn(), quit: vi.fn() }))
|
||||
open: vi.fn<(...args: unknown[]) => Promise<void>>(), write: vi.fn(), read: vi.fn(), quit: vi.fn(),
|
||||
messageBox: vi.fn<(parent: BrowserWindow, options: MessageBoxOptions) => Promise<{ response: number }>>() }))
|
||||
vi.mock('electron', () => ({ ipcMain: {
|
||||
handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
|
||||
removeHandler: (channel: string) => native.handlers.delete(channel),
|
||||
}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
|
||||
}, dialog: { showMessageBox: native.messageBox }, app: { quit: native.quit }, shell: { openExternal: native.open },
|
||||
clipboard: { writeText: native.write, readText: native.read } }))
|
||||
|
||||
type WindowFixture = EventEmitter & Pick<BrowserWindow, 'setMenu' | 'setTitle' | 'loadURL' | 'destroy' | 'isDestroyed'
|
||||
| 'isFocused' | 'isMinimized' | 'focus' | 'show' | 'restore'> & {
|
||||
| 'isVisible' | 'isFocused' | 'isMinimized' | 'focus' | 'show' | 'restore'> & {
|
||||
webContents: EventEmitter & Pick<WebContents, 'send' | 'setWindowOpenHandler'> & { mainFrame: Pick<WebFrameMain, 'url'> }
|
||||
}
|
||||
|
||||
@@ -26,27 +28,33 @@ function fakeWindow() {
|
||||
send: vi.fn(), setWindowOpenHandler: vi.fn() })
|
||||
return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(),
|
||||
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: (): boolean => false,
|
||||
isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
|
||||
isVisible: (): boolean => true, isFocused: (): boolean => true,
|
||||
isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
|
||||
}
|
||||
afterEach(() => { ui?.dispose(); vi.restoreAllMocks(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
|
||||
function setup(platform: NodeJS.Platform = 'darwin') {
|
||||
function setup(platform: NodeJS.Platform = 'darwin', beforeSync?: (surface: ReturnType<typeof fakeWindow>) => void) {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
native.messageBox.mockImplementation(() => new Promise(() => {}))
|
||||
window = fakeWindow()
|
||||
if (platform === 'win32') window.webContents.mainFrame.url = 'dsh-app://app/'
|
||||
const owner = platform === 'win32' ? window : fakeWindow()
|
||||
owner.webContents.mainFrame.url = 'dsh-app://app/'
|
||||
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
|
||||
let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' }
|
||||
const install = vi.fn(async () => update)
|
||||
const createOverlay = (): WindowFixture => window
|
||||
const parent = createOverlay as () => BrowserWindow
|
||||
ui = new DesktopMandatoryUpdateWindow({ overlays: { create: parent }, preload: 'owned', locale: resolveDesktopLocale('zh'),
|
||||
const create = createOverlay as () => BrowserWindow
|
||||
const currentParent = (): WindowFixture => platform === 'win32' ? window : owner
|
||||
const parent = currentParent as () => BrowserWindow
|
||||
ui = new DesktopMandatoryUpdateWindow({ overlays: { create }, preload: 'owned', locale: resolveDesktopLocale('zh'),
|
||||
allowedPageOrigins: ['https://downloads.example.com'], parent,
|
||||
policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install })
|
||||
beforeSync?.(window)
|
||||
ui.sync()
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
const view = () => native.handlers.get(MANDATORY_IPC.status)!(event) as MandatoryUpdateView
|
||||
const action = (name: string, version: string = '1.0.1-nightly.1', revision = view().confirmation?.revision) =>
|
||||
native.handlers.get(MANDATORY_IPC.action)!(event, name, version, revision)
|
||||
return { view, action, install, policy(next: DesktopPolicyState) { policy = next; ui!.sync() },
|
||||
return { view, action, install, owner, policy(next: DesktopPolicyState) { policy = next; ui!.sync() },
|
||||
update(next: DesktopUpdateState) { update = next; ui!.sync() } }
|
||||
}
|
||||
|
||||
@@ -140,15 +148,6 @@ it('rejects same-URL child frames and removes quit from renderer privileges', ()
|
||||
expect(() => f.action('quit')).toThrow(/invalid action/)
|
||||
})
|
||||
|
||||
it('exits the application when the mandatory window is closed without clearing the policy', () => {
|
||||
const f = setup()
|
||||
const event = { preventDefault: vi.fn() }
|
||||
window.emit('close', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(native.quit).toHaveBeenCalledOnce()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
|
||||
it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => {
|
||||
vi.useFakeTimers()
|
||||
const f = setup()
|
||||
@@ -212,3 +211,219 @@ it.each(['darwin', 'win32'] as const)('includes the installation wait notice onl
|
||||
? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。'
|
||||
: '更新期间应用将暂时关闭,完成后会自动打开。')
|
||||
})
|
||||
|
||||
it('updates the same macOS view in the background without showing or focusing the owner', () => {
|
||||
const f = setup()
|
||||
f.update({ phase: 'downloading', version: '1.0.1-nightly.1', percent: 35 })
|
||||
f.update({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'download' })
|
||||
expect(f.owner).not.toBe(window)
|
||||
expect(f.owner.show).not.toHaveBeenCalled()
|
||||
expect(f.owner.focus).not.toHaveBeenCalled()
|
||||
expect(window.loadURL).toHaveBeenCalledOnce()
|
||||
expect(ui!.confirmationWindow).toBe(f.owner)
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels outstanding approval when the mandatory view is destroyed without clearing policy', async () => {
|
||||
const f = setup()
|
||||
const approval = ui!.confirm('1.0.1-nightly.1', true)
|
||||
window.webContents.emit('destroyed')
|
||||
expect(await approval).toBe(false)
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['initial', 'recovery'] as const)('keeps the native owner title unchanged after %s view loading fails', async (stage) => {
|
||||
const failure = new Error('view load failed')
|
||||
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup('darwin', (surface) => {
|
||||
if (stage === 'initial') surface.loadURL.mockRejectedValueOnce(failure)
|
||||
})
|
||||
expect(f.owner).not.toBe(window)
|
||||
if (stage === 'recovery') {
|
||||
window.loadURL.mockRejectedValueOnce(failure)
|
||||
window.webContents.emit('render-process-gone')
|
||||
}
|
||||
await expect(window.loadURL.mock.results.at(-1)!.value).rejects.toBe(failure)
|
||||
expect(f.owner.setTitle).not.toHaveBeenCalled()
|
||||
expect(f.owner.show).not.toHaveBeenCalled()
|
||||
expect(f.owner.focus).not.toHaveBeenCalled()
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(report).toHaveBeenCalledWith(expect.stringContaining('mandatory update view'), failure)
|
||||
f.policy({ blocking: false, checking: false })
|
||||
ui!.dispose()
|
||||
expect(f.owner.setTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers native recovery until foreground and opens the policy download page without installing', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup('darwin')
|
||||
await Promise.resolve()
|
||||
vi.spyOn(f.owner, 'isFocused').mockReturnValue(false)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local load failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
expect(native.messageBox).not.toHaveBeenCalled()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
vi.spyOn(f.owner, 'isFocused').mockReturnValue(true)
|
||||
f.owner.emit('focus')
|
||||
f.owner.emit('focus')
|
||||
expect(native.messageBox).toHaveBeenCalledOnce()
|
||||
expect(native.messageBox).toHaveBeenCalledWith(f.owner, expect.objectContaining({
|
||||
message: '请更新后继续使用', cancelId: -1,
|
||||
buttons: ['打开下载页面', '退出'],
|
||||
}))
|
||||
expect(native.messageBox.mock.calls[0]![1].detail).toContain('1.0.1-nightly.1')
|
||||
answer.resolve({ response: 0 })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(window.loadURL).toHaveBeenCalledTimes(2)
|
||||
expect(native.open).toHaveBeenCalledWith('https://downloads.example.com/desktop')
|
||||
expect(f.install).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['clearance', 'dispose', 'hide'] as const)('ignores native recovery responses after %s', async (reason) => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup('darwin', (surface) => { surface.loadURL.mockRejectedValueOnce(new Error('local load failed')) })
|
||||
await Promise.resolve()
|
||||
const options = native.messageBox.mock.calls[0]![1]
|
||||
expect(options.signal!.aborted).toBe(false)
|
||||
if (reason === 'clearance') f.policy({ blocking: false, checking: false })
|
||||
else if (reason === 'dispose') ui!.dispose()
|
||||
else f.owner.emit('hide')
|
||||
expect(options.signal!.aborted).toBe(true)
|
||||
expect(f.owner.setTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the normal quit entry only for an explicit native recovery exit', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
const f = setup('darwin')
|
||||
await Promise.resolve()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
answer.resolve({ response: 1 })
|
||||
await Promise.resolve()
|
||||
expect(native.quit).toHaveBeenCalledOnce()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(f.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains blocking and reopens native recovery after a non-action dismissal', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
answer.resolve({ response: -1 })
|
||||
await Promise.resolve()
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(window.loadURL).toHaveBeenCalledTimes(2)
|
||||
expect(native.messageBox).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a late quit response after policy clearance', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: false, checking: false })
|
||||
answer.resolve({ response: 1 })
|
||||
await Promise.resolve()
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
it.each([undefined, 'https://unapproved.example.com/download'])('omits native download action for unavailable destination %s', async (page) => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: true, checking: false, ...(page === undefined ? {} : { page }) })
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
expect(native.messageBox.mock.calls[0]![1].buttons).toEqual(['退出'])
|
||||
expect(native.messageBox.mock.calls[0]![1].cancelId).toBe(-1)
|
||||
expect(native.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open a changed policy URL using approval for the previous destination', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: true, checking: false, page: 'https://downloads.example.com/new' })
|
||||
answer.resolve({ response: 0 })
|
||||
await Promise.resolve()
|
||||
expect(native.open).not.toHaveBeenCalled()
|
||||
expect(native.messageBox).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
||||
it('reports a browser-opening failure in the native fallback without claiming download success', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
native.open.mockRejectedValueOnce(new Error('browser unavailable'))
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
answer.resolve({ response: 0 })
|
||||
await vi.waitFor(() => { expect(native.messageBox).toHaveBeenCalledTimes(2) })
|
||||
expect(native.messageBox.mock.calls[1]![1].detail).toContain('无法打开下载页面,请重试')
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(f.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
it('uses the configured policy title as the native headline without repeating it in detail', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: true, checking: false, title: 'Configured title', detail: 'Configured detail' })
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
const options = native.messageBox.mock.calls[0]![1]
|
||||
expect(options.message).toBe('Configured title')
|
||||
expect(options.detail).toBe('Configured detail\n\n新版本:1.0.1-nightly.1')
|
||||
})
|
||||
|
||||
|
||||
it('rechecks recovery after an aborted native prompt finishes following a foreground return', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
f.owner.emit('hide')
|
||||
f.owner.emit('focus')
|
||||
expect(native.messageBox).toHaveBeenCalledOnce()
|
||||
answer.resolve({ response: 1 })
|
||||
await Promise.resolve()
|
||||
expect(native.messageBox).toHaveBeenCalledTimes(2)
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
@@ -46,13 +46,14 @@ afterEach(() => {
|
||||
vi.clearAllMocks(); vi.unstubAllEnvs()
|
||||
})
|
||||
function setup() {
|
||||
let blocked = false
|
||||
const removeChildView = vi.fn()
|
||||
const owner = Object.assign(new EventEmitter(), {
|
||||
webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView }, isDestroyed: () => false,
|
||||
})
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US', 'darwin')
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US', 'darwin', () => blocked)
|
||||
manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' })
|
||||
return { manager, owner, removeChildView }
|
||||
return { manager, owner, removeChildView, block: () => { blocked = true } }
|
||||
}
|
||||
|
||||
/** Invoke the first registered header interceptor for one request. */
|
||||
@@ -154,7 +155,7 @@ it('samples the language and UTC offset on every Platform request', async () =>
|
||||
const owner = Object.assign(new EventEmitter(), {
|
||||
webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, isDestroyed: () => false,
|
||||
})
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'darwin')
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'darwin', () => false)
|
||||
manager.setSession({ origin: 'https://platform.deepseek.com', userId: null, token: 'fixture-secret' })
|
||||
await manager.open(owner, 'usage', bounds)
|
||||
const offset = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(480)
|
||||
@@ -314,7 +315,7 @@ it('does not reveal a pending view after the owner reloads or remove a replaceme
|
||||
it('bootstraps the current language and updates an open view without reloading', async () => {
|
||||
const { owner } = setup()
|
||||
let locale: 'en_US' | 'zh_CN' = 'zh_CN'
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'win32')
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'win32', () => false)
|
||||
manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' })
|
||||
manager.notifyLocaleChanged()
|
||||
await manager.open(owner, 'usage', bounds)
|
||||
@@ -490,3 +491,25 @@ it('keeps an open temporary document when its credential gains a stable identity
|
||||
expect(browserSession().partition).toMatch(/^persist:/)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('refuses Platform admission while an update prompt is blocking', async () => {
|
||||
const f = setup()
|
||||
f.block()
|
||||
await expect(f.manager.open(f.owner, 'usage', { x: 0, y: 0, width: 400, height: 300 })).rejects.toThrow(/blocked/)
|
||||
expect(state.views).toHaveLength(0)
|
||||
expect(state.sessions).toHaveLength(0)
|
||||
await f.manager.dispose()
|
||||
})
|
||||
|
||||
it('refuses a pending Platform view if update blocking starts during storage cleanup', async () => {
|
||||
const f = setup()
|
||||
const pending = f.manager.open(f.owner, 'usage', { x: 0, y: 0, width: 400, height: 300 })
|
||||
expect(state.sessions).toHaveLength(1)
|
||||
f.block()
|
||||
await expect(pending).rejects.toThrow(/blocked/)
|
||||
expect(state.views).toHaveLength(0)
|
||||
expect(f.owner.contentView.addChildView).not.toHaveBeenCalled()
|
||||
expect(f.owner.listenerCount('closed')).toBe(0)
|
||||
await f.manager.dispose()
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInContext } from 'node:vm'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
it.each(['welcome', 'update-dialog', 'mandatory-update'].flatMap(document => [false, true].map(dark => ({ document, dark }))))(
|
||||
'$document applies the initial app palette and follows changes (dark=$dark)', ({ document, dark }) => {
|
||||
const dom = new JSDOM(readFileSync(new URL(`../renderer/${document}.html`, import.meta.url), 'utf8'), { runScripts: 'outside-only' })
|
||||
const palette = Object.assign(new dom.window.EventTarget(), { matches: dark, media: '(prefers-color-scheme: dark)',
|
||||
onchange: null, addListener: vi.fn(), removeListener: vi.fn() })
|
||||
const remove = vi.spyOn(palette, 'removeEventListener')
|
||||
dom.window.matchMedia = vi.fn(() => palette)
|
||||
try {
|
||||
expect(dom.window.document.querySelectorAll('script[src="shell-theme.js"]')).toHaveLength(1)
|
||||
runInContext(readFileSync(new URL('../renderer/shell-theme.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
|
||||
expect(dom.window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)')
|
||||
expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(dark)
|
||||
palette.matches = !dark
|
||||
palette.dispatchEvent(new dom.window.Event('change'))
|
||||
expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark)
|
||||
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
|
||||
expect(remove).toHaveBeenCalledWith('change', expect.any(Function))
|
||||
palette.matches = dark
|
||||
palette.dispatchEvent(new dom.window.Event('change'))
|
||||
expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark)
|
||||
} finally { dom.window.close() }
|
||||
})
|
||||
@@ -1,233 +1,215 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts'
|
||||
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC, type UpdateDialogOptions } from '../src/update-dialog.ts'
|
||||
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
|
||||
import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts'
|
||||
|
||||
const fixture = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
||||
const windows: FakeWindow[] = []
|
||||
class FakeWindow extends EventEmitter {
|
||||
const views: FakeView[] = []
|
||||
const load = { failure: false }
|
||||
class FakeParent extends EventEmitter {
|
||||
destroyed = false
|
||||
readonly webContents = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
insertCSS: vi.fn(async () => 'blur'),
|
||||
removeInsertedCSS: vi.fn(async () => {}),
|
||||
})
|
||||
readonly focus = vi.fn()
|
||||
readonly show = vi.fn()
|
||||
readonly hide = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
readonly loadURL = vi.fn(async () => {})
|
||||
constructor(readonly options: unknown) { super(); windows.push(this) }
|
||||
readonly isFocused = vi.fn(() => true)
|
||||
readonly contentView = { addChildView: vi.fn(), removeChildView: vi.fn() }
|
||||
readonly webContents = Object.assign(new EventEmitter(), { focus: vi.fn() })
|
||||
getContentBounds() { return { x: 10, y: 20, width: 900, height: 650 } }
|
||||
isDestroyed() { return this.destroyed }
|
||||
isVisible() { return true }
|
||||
destroy() { this.destroyed = true; this.emit('closed') }
|
||||
setMenu() {}
|
||||
}
|
||||
return { handlers, windows, FakeWindow }
|
||||
class FakeView {
|
||||
destroyed = false
|
||||
readonly webContents = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(), focus: vi.fn(),
|
||||
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
|
||||
setWindowOpenHandler: vi.fn<(handler: () => { action: 'deny' }) => void>(),
|
||||
isDestroyed: () => this.destroyed,
|
||||
loadURL: vi.fn(async () => { if (load.failure) throw new Error('load failed'); this.webContents.emit('did-finish-load') }),
|
||||
close: vi.fn(() => { this.destroyed = true; this.webContents.emit('destroyed') }),
|
||||
})
|
||||
readonly setBackgroundColor = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
constructor(readonly options: unknown) { views.push(this) }
|
||||
}
|
||||
return { handlers, views, FakeParent, FakeView, load }
|
||||
})
|
||||
vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: {
|
||||
vi.mock('electron', () => ({ WebContentsView: fixture.FakeView, ipcMain: {
|
||||
handle: (name: string, fn: (...args: unknown[]) => unknown) => fixture.handlers.set(name, fn),
|
||||
removeHandler: (name: string) => fixture.handlers.delete(name),
|
||||
} }))
|
||||
|
||||
let dialogs: DesktopUpdateDialog | undefined
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
dialogs?.dispose()
|
||||
dialogs = undefined
|
||||
for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy()
|
||||
fixture.windows.length = 0
|
||||
vi.useRealTimers()
|
||||
fixture.views.length = 0
|
||||
fixture.handlers.clear()
|
||||
fixture.load.failure = false
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) {
|
||||
const parent = new fixture.FakeWindow({})
|
||||
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, new DesktopUpdateOverlays())
|
||||
const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, {
|
||||
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }),
|
||||
const parent = new fixture.FakeParent()
|
||||
const owner = parent as unknown as BrowserWindow
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, overlays)
|
||||
const show = (signal?: AbortSignal, options: Partial<UpdateDialogOptions> = {}) => dialogs!.show(owner, {
|
||||
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...options, ...(signal === undefined ? {} : { signal }),
|
||||
})
|
||||
const invoke = (channel: string, ...args: unknown[]) => {
|
||||
const window = fixture.windows.at(-1)!
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number }
|
||||
return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args))
|
||||
const view = fixture.views.at(-1)!
|
||||
const event = { sender: view.webContents, senderFrame: view.webContents.mainFrame }
|
||||
const state = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number }
|
||||
return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [state.revision, ...args] : args))
|
||||
}
|
||||
return { parent, show, invoke }
|
||||
return { parent, owner, show, invoke, overlays }
|
||||
}
|
||||
|
||||
it('accepts only a displayed choice from its own main frame and retains cancellation outside the button list', async () => {
|
||||
it('accepts only a displayed choice from its isolated shell frame', async () => {
|
||||
const f = setup()
|
||||
expect(dialogs!.isOpen).toBe(false)
|
||||
const pending = f.show()
|
||||
const view = fixture.views[0]!
|
||||
expect(dialogs!.isOpen).toBe(true)
|
||||
const window = fixture.windows.at(-1)!
|
||||
expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ closeLabel: '关闭', buttons: ['安装并重启'], cancelId: 1 })
|
||||
const respond = fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!
|
||||
expect(() => respond({ sender: {}, senderFrame: window.webContents.mainFrame }, 0)).toThrow(/unowned/)
|
||||
expect(() => respond({ sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } }, 0)).toThrow(/unowned/)
|
||||
expect(() => respond({ sender: f.parent.webContents, senderFrame: view.webContents.mainFrame }, 0)).toThrow(/unowned/)
|
||||
expect(() => respond({ sender: view.webContents, senderFrame: { ...view.webContents.mainFrame } }, 0)).toThrow(/unowned/)
|
||||
for (const index of [-1, 2, '0', 0.5, NaN]) expect(() => f.invoke(UPDATE_DIALOG_IPC.respond, index)).toThrow(/invalid/)
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 1)
|
||||
expect(await pending).toEqual({ response: 1, checkboxChecked: false })
|
||||
expect(dialogs!.isOpen).toBe(false)
|
||||
const next = f.show()
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
|
||||
expect((await next).response).toBe(0)
|
||||
})
|
||||
|
||||
it('releases visible Windows modality before destroying a dismissed dialog', async () => {
|
||||
vi.useFakeTimers()
|
||||
it('attaches within its owner and resizes below the Windows caption without a native child window', async () => {
|
||||
const f = setup()
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const destroy = vi.spyOn(window, 'destroy').mockImplementation(() => {
|
||||
expect(window.hide).toHaveBeenCalledTimes(process.platform === 'win32' ? 1 : 0)
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
})
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
|
||||
await pending
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('follows the parent geometry and removes listeners when closed or replaced', async () => {
|
||||
const f = setup()
|
||||
const first = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 })
|
||||
window.emit('ready-to-show')
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
const view = fixture.views[0]!
|
||||
expect(f.parent.contentView.addChildView).toHaveBeenCalledWith(view)
|
||||
expect(view.options).toMatchObject({ webPreferences: { preload: 'preload-update-dialog.cjs', sandbox: true, contextIsolation: true } })
|
||||
expect(view.setBackgroundColor).toHaveBeenCalledWith('#00000000')
|
||||
const top = process.platform === 'win32' ? 40 : 0
|
||||
expect(view.setBounds).toHaveBeenCalledWith({ x: 0, y: top, width: 900, height: 650 - top })
|
||||
f.parent.emit('resize')
|
||||
expect(window.setBounds).toHaveBeenCalledWith(f.parent.getContentBounds())
|
||||
const next = f.show()
|
||||
expect((await first).response).toBe(1)
|
||||
expect(f.parent.listenerCount('resize')).toBe(1)
|
||||
fixture.windows.at(-1)!.destroy()
|
||||
expect((await next).response).toBe(1)
|
||||
expect(f.parent.listenerCount('resize')).toBe(0)
|
||||
expect(f.parent.listenerCount('move')).toBe(0)
|
||||
expect(f.parent.webContents.insertCSS).not.toHaveBeenCalled()
|
||||
expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => {
|
||||
const f = setup()
|
||||
const controller = new AbortController()
|
||||
const pending = f.show(controller.signal)
|
||||
controller.abort()
|
||||
expect((await pending).response).toBe(1)
|
||||
expect((await f.show(controller.signal)).response).toBe(1)
|
||||
const crashing = f.show()
|
||||
fixture.windows.at(-1)!.webContents.emit('render-process-gone')
|
||||
expect((await crashing).response).toBe(1)
|
||||
const stopping = f.show()
|
||||
dialogs!.dispose()
|
||||
expect((await stopping).response).toBe(1)
|
||||
expect(fixture.handlers.size).toBe(0)
|
||||
const count = fixture.windows.length
|
||||
expect((await f.show()).response).toBe(1)
|
||||
expect(fixture.windows).toHaveLength(count)
|
||||
expect(view.setBounds).toHaveBeenCalledTimes(2)
|
||||
f.parent.destroy()
|
||||
expect((await f.show()).response).toBe(1)
|
||||
expect((await pending).response).toBe(1)
|
||||
expect(view.webContents.close).toHaveBeenCalledOnce()
|
||||
expect(f.parent.listenerCount('resize')).toBe(0)
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('denies navigation away from the owned document', async () => {
|
||||
it('updates a background result in the same view without activating its owner', async () => {
|
||||
const f = setup()
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const event = { preventDefault: vi.fn() }
|
||||
window.webContents.emit('will-navigate', event, 'https://example.com')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
window.webContents.emit('will-navigate', event, 'dsh-app://shell/update-dialog.html')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('supplies localized disclosure copy without putting diagnostics in the ordinary detail', async () => {
|
||||
const f = setup()
|
||||
const pending = dialogs!.show(f.parent as unknown as BrowserWindow, {
|
||||
message: '未能安全停止任务', technicalDetails: 'exit 0; shutdown acknowledged false',
|
||||
})
|
||||
expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ detail: '',
|
||||
technicalDetails: 'exit 0; shutdown acknowledged false', technicalDetailsLabel: '查看技术详情' })
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => {
|
||||
const f = setup()
|
||||
const abort = new AbortController()
|
||||
const first = f.show(abort.signal)
|
||||
const window = fixture.windows.at(-1)!
|
||||
f.parent.isFocused.mockReturnValue(false)
|
||||
const first = f.show()
|
||||
await Promise.resolve()
|
||||
const view = fixture.views[0]!
|
||||
const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number }
|
||||
const next = f.show()
|
||||
const next = f.show(undefined, { message: '已是最新版本' })
|
||||
expect((await first).response).toBe(1)
|
||||
expect(fixture.windows).toHaveLength(2)
|
||||
expect(window.loadURL).toHaveBeenCalledOnce()
|
||||
expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]])
|
||||
expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
abort.abort()
|
||||
expect(fixture.views).toHaveLength(1)
|
||||
expect(f.parent.contentView.addChildView).toHaveBeenCalledOnce()
|
||||
expect(view.webContents.loadURL).toHaveBeenCalledOnce()
|
||||
expect(f.parent.focus).not.toHaveBeenCalled()
|
||||
expect(view.webContents.focus).not.toHaveBeenCalled()
|
||||
expect(view.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, expect.objectContaining({ message: '已是最新版本' }))
|
||||
expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({
|
||||
sender: window.webContents, senderFrame: window.webContents.mainFrame,
|
||||
sender: view.webContents, senderFrame: view.webContents.mainFrame,
|
||||
}, old.revision, 0)).toThrow(/stale/)
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
|
||||
expect((await next).response).toBe(0)
|
||||
})
|
||||
|
||||
it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => {
|
||||
it('releases keyboard blocking only after fade-out and retains it across replacement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = setup()
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 0, blocked: false })
|
||||
const first = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const view = fixture.views[0]!
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 1, blocked: true })
|
||||
dialogs!.cancel()
|
||||
await first
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null)
|
||||
expect(view.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null)
|
||||
const next = f.show()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(view.destroyed).toBe(false)
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 1, blocked: true })
|
||||
dialogs!.cancel()
|
||||
await next
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(window.isDestroyed()).toBe(true)
|
||||
expect(f.parent.listenerCount('focus')).toBe(0)
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(view.destroyed).toBe(true)
|
||||
expect(f.parent.contentView.removeChildView).toHaveBeenCalledWith(view)
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 2, blocked: false })
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => {
|
||||
it('blocks owner input and restores dialog focus only when that owner is focused', async () => {
|
||||
const f = setup()
|
||||
f.parent.isFocused.mockReturnValue(false)
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
await Promise.resolve()
|
||||
const view = fixture.views[0]!
|
||||
const event = { preventDefault: vi.fn() }
|
||||
f.parent.webContents.emit('before-input-event', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(view.webContents.focus).not.toHaveBeenCalled()
|
||||
f.parent.isFocused.mockReturnValue(true)
|
||||
f.parent.emit('focus')
|
||||
expect(window.focus).toHaveBeenCalledTimes(2)
|
||||
expect(view.webContents.focus).toHaveBeenCalledOnce()
|
||||
dialogs!.focus()
|
||||
expect(f.parent.focus).toHaveBeenCalledOnce()
|
||||
dialogs!.dispose()
|
||||
await pending
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(f.parent.listenerCount('focus')).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the current locale for each presentation', async () => {
|
||||
it('cancels on abort, renderer failure, failed load, disposal, or a destroyed owner', async () => {
|
||||
const f = setup()
|
||||
const abort = new AbortController()
|
||||
const pending = f.show(abort.signal)
|
||||
abort.abort()
|
||||
expect((await pending).response).toBe(1)
|
||||
expect((await f.show(abort.signal)).response).toBe(1)
|
||||
const crashing = f.show()
|
||||
fixture.views.at(-1)!.webContents.emit('render-process-gone')
|
||||
expect((await crashing).response).toBe(1)
|
||||
fixture.load.failure = true
|
||||
expect((await f.show()).response).toBe(1)
|
||||
fixture.load.failure = false
|
||||
const stopping = f.show()
|
||||
dialogs!.dispose()
|
||||
expect((await stopping).response).toBe(1)
|
||||
expect(fixture.handlers.size).toBe(0)
|
||||
expect((await f.show()).response).toBe(1)
|
||||
f.parent.destroy()
|
||||
expect((await f.show()).response).toBe(1)
|
||||
})
|
||||
|
||||
it('denies foreign navigation, redirects, and new windows', async () => {
|
||||
const f = setup()
|
||||
const pending = f.show()
|
||||
const contents = fixture.views[0]!.webContents
|
||||
const event = { preventDefault: vi.fn() }
|
||||
contents.emit('will-navigate', event, 'https://example.com')
|
||||
contents.emit('will-navigate', event, 'dsh-app://shell/update-dialog.html')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
contents.emit('will-redirect', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(2)
|
||||
expect(contents.setWindowOpenHandler.mock.calls[0]![0]()).toEqual({ action: 'deny' })
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('reads the current locale and keeps technical diagnostics separate from ordinary detail', async () => {
|
||||
let locale = resolveDesktopLocale('en')
|
||||
const f = setup(() => locale)
|
||||
for (const language of ['zh-CN', 'en']) {
|
||||
locale = resolveDesktopLocale(language)
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
expect(fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event)).toMatchObject({
|
||||
locale: locale.id, closeLabel: locale.messages.updateClose,
|
||||
technicalDetailsLabel: locale.messages.updateTechnicalDetails,
|
||||
})
|
||||
const pending = f.show(undefined, { technicalDetails: 'exit 0; shutdown acknowledged false' })
|
||||
expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ locale: locale.id, closeLabel: locale.messages.updateClose,
|
||||
detail: '', technicalDetails: 'exit 0; shutdown acknowledged false', technicalDetailsLabel: locale.messages.updateTechnicalDetails })
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
}
|
||||
|
||||
@@ -1,160 +1,83 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() }))
|
||||
vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } }))
|
||||
|
||||
const native = vi.hoisted(() => ({ create: vi.fn<(options: object) => object>() }))
|
||||
vi.mock('electron', () => ({ WebContentsView: function (options: object) { return native.create(options) } }))
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function visibilityFixture(visible = true) {
|
||||
const visibility = { visible }
|
||||
const parent: ParentFixture & Pick<BrowserWindow, 'isVisible'> = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: (): boolean => false,
|
||||
isVisible: () => visibility.visible,
|
||||
function fixture() {
|
||||
let destroyed = false
|
||||
const parent = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 50, y: 40, width: 900, height: 650 }), isDestroyed: () => destroyed,
|
||||
isFocused: vi.fn(() => false), focus: vi.fn(), show: vi.fn(),
|
||||
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
|
||||
webContents: Object.assign(new EventEmitter(), { focus: vi.fn() }),
|
||||
destroy() { destroyed = true; parent.emit('closed') },
|
||||
})
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
destroyed: false,
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed,
|
||||
})
|
||||
native.create.mockReturnValue(window)
|
||||
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
|
||||
return { parent, window, visibility }
|
||||
}
|
||||
|
||||
it('restores a ready overlay each time its parent is shown and releases visibility ownership on close', async () => {
|
||||
const { parent, window, visibility } = visibilityFixture()
|
||||
window.emit('ready-to-show')
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
for (let index = 0; index < 2; index++) {
|
||||
visibility.visible = false
|
||||
parent.emit('hide')
|
||||
visibility.visible = true
|
||||
parent.emit('show')
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(window.show).toHaveBeenCalledTimes(3)
|
||||
await Promise.resolve()
|
||||
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
expect(parent.listenerCount('show')).toBe(0)
|
||||
expect(parent.webContents.insertCSS).not.toHaveBeenCalled()
|
||||
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
parent.emit('show')
|
||||
expect(window.show).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('waits for both a visible parent and a ready document, in either order', async () => {
|
||||
for (const readyFirst of [true, false]) {
|
||||
const { parent, window, visibility } = visibilityFixture(false)
|
||||
if (readyFirst) window.emit('ready-to-show')
|
||||
else { visibility.visible = true; parent.emit('show') }
|
||||
expect(window.show).not.toHaveBeenCalled()
|
||||
if (readyFirst) { visibility.visible = true; parent.emit('show') }
|
||||
else window.emit('ready-to-show')
|
||||
await Promise.resolve()
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not show or change parent styles when closed before its document is ready', async () => {
|
||||
const { parent, window, visibility } = visibilityFixture(false)
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
window.emit('ready-to-show')
|
||||
visibility.visible = true
|
||||
parent.emit('show')
|
||||
await Promise.resolve()
|
||||
expect(window.show).not.toHaveBeenCalled()
|
||||
expect(parent.listenerCount('show')).toBe(0)
|
||||
expect(parent.webContents.insertCSS).not.toHaveBeenCalled()
|
||||
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases a macOS overlay after its parent has already been destroyed', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const { parent, window } = visibilityFixture()
|
||||
await Promise.resolve()
|
||||
vi.spyOn(parent, 'isDestroyed').mockReturnValue(true)
|
||||
Object.defineProperty(parent, 'webContents', { get() { throw new Error('Object has been destroyed') } })
|
||||
window.destroyed = true
|
||||
expect(() => window.emit('closed')).not.toThrow()
|
||||
for (const event of ['focus', 'move', 'resize', 'show']) expect(parent.listenerCount(event)).toBe(0)
|
||||
})
|
||||
|
||||
type ParentFixture = EventEmitter & Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed'> & {
|
||||
webContents: EventEmitter & Pick<WebContents, 'insertCSS' | 'removeInsertedCSS'>
|
||||
}
|
||||
|
||||
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const parent: ParentFixture = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: () => false,
|
||||
})
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
|
||||
})
|
||||
native.create.mockReturnValue(window)
|
||||
try {
|
||||
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
|
||||
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
|
||||
const event = { preventDefault: vi.fn() }
|
||||
parent.webContents.emit('before-input-event', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(window.focus).toHaveBeenCalledOnce()
|
||||
window.emit('closed')
|
||||
expect(parent.listenerCount('focus')).toBe(0)
|
||||
expect(parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
} finally { platform.mockRestore() }
|
||||
})
|
||||
|
||||
it('blocks each parent until its last owned overlay closes and invalidates each transition', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
onTestFinished(() => { platform.mockRestore() })
|
||||
const parent = (): ParentFixture => Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: () => false,
|
||||
})
|
||||
const firstParent = parent() as BrowserWindow
|
||||
const secondParent = parent() as BrowserWindow
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const isolated = new DesktopUpdateOverlays()
|
||||
native.create.mockImplementation(() => {
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
|
||||
const views: ReturnType<typeof view>[] = []
|
||||
function view() {
|
||||
let closed = false
|
||||
const webContents = Object.assign(new EventEmitter(), { focus: vi.fn(), isDestroyed: () => closed,
|
||||
loadURL: vi.fn(async () => {}), setWindowOpenHandler: vi.fn(),
|
||||
close: vi.fn(() => { if (!closed) { closed = true; webContents.emit('destroyed') } }),
|
||||
})
|
||||
onTestFinished(() => { window.emit('closed') })
|
||||
return window
|
||||
})
|
||||
expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false })
|
||||
const first = overlays.create(firstParent, 'owned', 'Update required', false)
|
||||
const second = overlays.create(firstParent, 'owned', 'Confirm installation', false)
|
||||
const other = overlays.create(secondParent, 'owned', 'Update required', false)
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true })
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
|
||||
expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false })
|
||||
first.emit('closed')
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true })
|
||||
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1)
|
||||
second.emit('closed')
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false })
|
||||
expect(firstParent.listenerCount('move')).toBe(0)
|
||||
expect(firstParent.listenerCount('resize')).toBe(0)
|
||||
expect(firstParent.listenerCount('focus')).toBe(0)
|
||||
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
|
||||
other.emit('closed')
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false })
|
||||
return { get webContents() { if (closed) throw new Error('Destroyed native view'); return webContents },
|
||||
setBounds: vi.fn(), setBackgroundColor: vi.fn() }
|
||||
}
|
||||
native.create.mockImplementation(() => { const next = view(); views.push(next); return next })
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const owner = parent
|
||||
return { parent, owner, views, overlays, create: () => overlays.create(owner, 'isolated-preload') }
|
||||
}
|
||||
|
||||
it('never shows or activates its native owner on visibility or background loading', () => {
|
||||
const f = fixture(); const overlay = f.create()
|
||||
expect(f.parent.contentView.addChildView).toHaveBeenCalledWith(f.views[0])
|
||||
f.parent.emit('show'); f.parent.emit('hide'); f.parent.emit('show')
|
||||
overlay.webContents.emit('did-finish-load')
|
||||
expect(f.parent.show).not.toHaveBeenCalled()
|
||||
expect(f.parent.focus).not.toHaveBeenCalled()
|
||||
expect(f.views[0]!.webContents.focus).not.toHaveBeenCalled()
|
||||
expect(f.parent.listenerCount('show')).toBe(0)
|
||||
overlay.destroy()
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32'] as const)('fits inside the owner with native caption clearance on %s', (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
const f = fixture(); const overlay = f.create(); const top = platform === 'win32' ? 40 : 0
|
||||
expect(f.views[0]!.setBounds).toHaveBeenCalledWith({ x: 0, y: top, width: 900, height: 650 - top })
|
||||
f.parent.emit('resize'); expect(f.views[0]!.setBounds).toHaveBeenCalledTimes(2)
|
||||
overlay.destroy()
|
||||
expect(f.parent.listenerCount('resize')).toBe(0)
|
||||
})
|
||||
|
||||
it('blocks keyboard input and redirects focus only while the owner is focused', () => {
|
||||
const f = fixture(); const overlay = f.create(); const event = { preventDefault: vi.fn() }
|
||||
f.parent.webContents.emit('before-input-event', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.views[0]!.webContents.focus).not.toHaveBeenCalled()
|
||||
f.parent.isFocused.mockReturnValue(true); f.parent.emit('focus')
|
||||
expect(f.views[0]!.webContents.focus).toHaveBeenCalledOnce()
|
||||
overlay.destroy()
|
||||
expect(f.parent.webContents.focus).toHaveBeenCalledOnce()
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps input blocked until every view is released and changes the shortcut revision', () => {
|
||||
const f = fixture(); const first = f.create(); const second = f.create()
|
||||
expect(f.overlays.input(f.owner)).toEqual({ blocked: true, revision: 2 })
|
||||
first.destroy(); expect(f.overlays.input(f.owner)).toEqual({ blocked: true, revision: 3 })
|
||||
first.destroy(); second.destroy()
|
||||
expect(f.overlays.input(f.owner)).toEqual({ blocked: false, revision: 4 })
|
||||
expect(new DesktopUpdateOverlays().input(f.owner)).toEqual({ blocked: false, revision: 0 })
|
||||
})
|
||||
|
||||
it('closes the view after owner destruction without accessing destroyed native contents', () => {
|
||||
const f = fixture(); const overlay = f.create()
|
||||
Object.defineProperty(f.parent, 'webContents', { get() { throw new Error('Destroyed owner') } })
|
||||
expect(() => { f.parent.destroy() }).not.toThrow()
|
||||
expect(overlay.isDestroyed()).toBe(true)
|
||||
expect(f.parent.listenerCount('focus')).toBe(0)
|
||||
expect(f.parent.listenerCount('closed')).toBe(0)
|
||||
expect(f.parent.contentView.removeChildView).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -54,9 +54,9 @@ export default defineConfig([
|
||||
await build({
|
||||
configFile: false,
|
||||
plugins: [{
|
||||
name: 'desktop-brand-font',
|
||||
name: 'desktop-shell-assets',
|
||||
async generateBundle() {
|
||||
for (const name of ['brand-font.css', 'montserrat-regular.woff2', 'montserrat-light.woff2', 'montserrat-medium.woff2', 'Montserrat-OFL.txt']) {
|
||||
for (const name of ['design-platform.css', 'brand-font.css', 'montserrat-regular.woff2', 'montserrat-light.woff2', 'montserrat-medium.woff2', 'Montserrat-OFL.txt']) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: name,
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/ws": "8.18.1",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
// Session-header background jobs driven by a real `ctx.jobs` entry. No model
|
||||
// call is involved.
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
@@ -87,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
|
||||
const trigger = page.getByRole('button', { name: '1 background job running' })
|
||||
expect(await trigger.count()).toBe(0)
|
||||
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
const started = await executeToolFixture(scaffold.ctx, {
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('background-job-list-e2e'),
|
||||
name: 'bash',
|
||||
|
||||
@@ -78,6 +78,7 @@ describe('web e2e: blank Session reuse under the requested preset', () => {
|
||||
}
|
||||
|
||||
async function storedSelections(sessionId: SessionId): Promise<unknown[]> {
|
||||
await scaffold.ctx.sessionPersistence.flush()
|
||||
const handle = await scaffold.ctx.sessionPersistence.open(sessionId, 'read')
|
||||
try {
|
||||
const { events } = await handle.read()
|
||||
|
||||
@@ -56,9 +56,9 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }])
|
||||
// Application consent remains independent of the session's tool-approval policy.
|
||||
const grant = scaffold.ctx.tools.execute({ agent, signal: new AbortController().signal,
|
||||
callId: ToolCallId('cua-app-approval'), name: 'run_code',
|
||||
arguments: { code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })',
|
||||
description: 'Request application access' } })
|
||||
calls: [{ type: 'tool-call', id: ToolCallId('cua-app-approval'), name: 'run_code',
|
||||
arguments: JSON.stringify({ code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })',
|
||||
description: 'Request application access' }) }] })
|
||||
const approval = page.locator('[data-approval-key]')
|
||||
await Promise.race([approval.waitFor(), grant.then((result) => {
|
||||
throw new Error(`Application selection completed before consent: ${JSON.stringify(result)}`)
|
||||
@@ -73,7 +73,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-approval.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await approval.getByRole('button', { name: 'Always allow', exact: true }).click()
|
||||
expect((await grant).isError).toBe(false)
|
||||
expect((await grant).results).toMatchObject([{ result: { isError: false } }])
|
||||
await approval.waitFor({ state: 'hidden' })
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# NavScenario: first run bash to
|
||||
|
||||
Session file:
|
||||
|
||||
```text
|
||||
{{sessionFile}}
|
||||
```
|
||||
|
||||
> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.
|
||||
|
||||
<details><summary>3 previous messages</summary>
|
||||
|
||||
**Tool: `bash`**
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "echo NAVIGATION_OK",
|
||||
"description": "Print NAVIGATION_OK"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
NAVIGATION_OK
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-a.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-a.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # alpha nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-b.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-b.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # beta nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
FIRST_DONE
|
||||
|
||||
> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.
|
||||
|
||||
## Navigation Summary
|
||||
|
||||
- alpha nav
|
||||
- beta nav
|
||||
|
||||
```
|
||||
echo WATERFALL
|
||||
```
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -116,7 +117,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => {
|
||||
const opened = scaffold.ctx.agents.get(SEED_ID)
|
||||
if (opened === undefined) throw new Error('opened session has no live Agent')
|
||||
agent = opened
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
const started = await executeToolFixture(scaffold.ctx, {
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('jobs-menu-layering-e2e'),
|
||||
name: 'bash',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
// Session-header job list driven by a real background bash run: the job row
|
||||
// arrives over the job roster stream, expanding it opens the output
|
||||
// observation stream, and the panel shows the process's real output while it
|
||||
@@ -87,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
|
||||
const trigger = page.getByRole('button', { name: '1 background job running' })
|
||||
expect(await trigger.count()).toBe(0)
|
||||
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
const started = await executeToolFixture(scaffold.ctx, {
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('live-job-stream-e2e'),
|
||||
name: 'bash',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -97,14 +98,14 @@ describe('minimal agent preset', () => {
|
||||
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
|
||||
await mkdir(stateDir)
|
||||
const signal = new AbortController().signal
|
||||
await scaffold.ctx.tools.execute({
|
||||
await executeToolFixture(scaffold.ctx, {
|
||||
signal,
|
||||
callId: ToolCallId('minimal-bash-state-setup'),
|
||||
name: 'bash',
|
||||
arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
const bash = await executeToolFixture(scaffold.ctx, {
|
||||
signal,
|
||||
callId: ToolCallId('minimal-bash-state-read'),
|
||||
name: 'bash',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// under the standard discipline: turn 1 produces a bash call plus two
|
||||
// parallel reads in one assistant message (tool-call density for the
|
||||
// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, Response } from 'playwright'
|
||||
@@ -14,7 +14,7 @@ import { chromium } from 'playwright'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -32,6 +32,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
|
||||
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
|
||||
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
|
||||
const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
|
||||
@@ -424,6 +425,66 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function')
|
||||
expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function')
|
||||
await page.evaluate(() => {
|
||||
const clipboard = navigator.clipboard
|
||||
const originalWrite = clipboard.write.bind(clipboard)
|
||||
Reflect.set(globalThis, '__dshClipboardWrites', 0)
|
||||
Object.defineProperty(clipboard, 'write', {
|
||||
configurable: true,
|
||||
value: (...args: Parameters<typeof originalWrite>) => {
|
||||
Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1)
|
||||
return originalWrite(...args)
|
||||
},
|
||||
})
|
||||
})
|
||||
const markdownResponse = page.waitForResponse(response =>
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 })
|
||||
const copyButton = page.getByRole('button', { name: 'More actions' })
|
||||
await copyButton.click()
|
||||
await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click()
|
||||
const response = await markdownResponse
|
||||
expect(response.status()).toBe(200)
|
||||
expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8')
|
||||
await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1)
|
||||
|
||||
const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path
|
||||
if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location')
|
||||
expect((await stat(sourcePath)).isFile()).toBe(true)
|
||||
expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true)
|
||||
const markdown = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``)
|
||||
const normalized = markdown
|
||||
.split(sourcePath).join('{{sessionFile}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
// compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one.
|
||||
await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE)
|
||||
expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```')
|
||||
expect(normalized).toContain('# NavScenario: first run bash to')
|
||||
expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK')
|
||||
expect(normalized).toContain('<details><summary>3 previous messages</summary>')
|
||||
expect(normalized).toContain('**Tool: `bash`**')
|
||||
expect(normalized).toContain('NAVIGATION_OK')
|
||||
expect(normalized).toContain('FIRST_DONE')
|
||||
expect(normalized).toContain('Navigation Summary')
|
||||
expect(normalized).not.toContain('The user wants me to follow')
|
||||
expect(normalized).not.toContain('{{system}}')
|
||||
expect(normalized).not.toContain('request/header')
|
||||
expect(normalized.endsWith('\n')).toBe(true)
|
||||
expect(normalized.endsWith('\n\n')).toBe(false)
|
||||
|
||||
await copyButton.click()
|
||||
expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false)
|
||||
await page.keyboard.press('Escape')
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
await ensureSeedOpen(page)
|
||||
@@ -561,7 +622,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
|
||||
await assertFixtureInventory(CODE_CARD_DIR, [
|
||||
'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md',
|
||||
])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
|
||||
@@ -30,7 +30,10 @@ describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => {
|
||||
const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
|
||||
const tripwire = watchConsole(page)
|
||||
try {
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await Promise.all([
|
||||
page.waitForEvent('console', message => message.text().startsWith('initial Session restoration failed:')),
|
||||
page.goto(scaffold.authenticatedUrl),
|
||||
])
|
||||
// Startup prepares the default Workspace, and its own blank-Session
|
||||
// attempt is refused without a notice: the hero stays on the picker.
|
||||
await page.getByRole('button', { name: '选择工作区', exact: true }).waitFor()
|
||||
@@ -38,8 +41,8 @@ describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => {
|
||||
expect(scaffold.ctx.sessions.list()).toEqual([])
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
|
||||
// The capsule: on a local build the wordmark's fallback copies the version.
|
||||
await page.getByRole('button', { name: '新建会话', exact: true }).last().click()
|
||||
await page.getByRole('treeitem').filter({ hasText: '默认工作区' }).hover()
|
||||
await page.getByRole('button', { name: '在“默认工作区”中新建会话', exact: true }).click()
|
||||
const notice = page.getByRole('alert').filter({ hasText: '新建会话失败' })
|
||||
await notice.waitFor()
|
||||
await compareOrRefreshGolden(NOTICE_EXPECTED, await notice.ariaSnapshot(), MODE)
|
||||
|
||||
@@ -68,9 +68,43 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls:
|
||||
})
|
||||
}
|
||||
|
||||
// The options carry long descriptions on purpose: the squeeze assertion below
|
||||
// needs option copy that WRAPS, which is the only text layout that reproduces a
|
||||
// collapsed row painting its copy outside its own box.
|
||||
/**
|
||||
* Read how the composer draws one long-copy element: the lines it shows,
|
||||
* whether CSS clipped it, whether its text overflows the line budget, and what
|
||||
* its toggle does while that toggle is visible.
|
||||
* @param element - a question heading or an option description.
|
||||
* @returns the drawn state of the copy.
|
||||
*/
|
||||
function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } {
|
||||
const style = getComputedStyle(element)
|
||||
const toggle = element.tagName === 'H2'
|
||||
? element.nextElementSibling
|
||||
: element.closest('[data-option-row]')?.querySelector(':scope > button:not([role])')
|
||||
return {
|
||||
// A clipped box keeps its full budget and gives the dropped line back through its bottom margin.
|
||||
lines: Math.round((element.getBoundingClientRect().height + parseFloat(style.marginBottom)) / parseFloat(style.lineHeight)),
|
||||
clipped: style.maskImage !== 'none',
|
||||
overflows: element.scrollHeight > element.clientHeight + 0.5,
|
||||
toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible'
|
||||
? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand'
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether an expanded copy's toggle sits below the copy's last text line.
|
||||
* @param toggle - the copy's toggle button.
|
||||
* @param copy - the expanded question heading or option description.
|
||||
* @returns whether the toggle starts at or below the bottom of the text; false without the copy.
|
||||
*/
|
||||
function belowText(toggle: HTMLElement, copy: Element | null): boolean {
|
||||
if (copy === null) return false
|
||||
const textBottom = copy.getBoundingClientRect().bottom - parseFloat(getComputedStyle(copy).paddingBottom)
|
||||
return toggle.getBoundingClientRect().top >= textBottom - 0.5
|
||||
}
|
||||
|
||||
// The options carry descriptions on purpose: the squeeze assertion below
|
||||
// verifies that each description keeps to its two-line budget in the card scrollport.
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -338,53 +372,85 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
// This golden owns the stable question surface; the answered-state
|
||||
// golden below owns the resulting transcript.
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes =>
|
||||
nodes.map(node => ({
|
||||
label: node.getAttribute('aria-label'),
|
||||
description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/)
|
||||
.filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '),
|
||||
})))
|
||||
await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE)
|
||||
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
}
|
||||
|
||||
// Squeezed card: the option rows are the capped card's scroll content, so
|
||||
// shrinking the seat must push overflow into the option list, never
|
||||
// collapse a row below the height its own copy needs — a collapsed row
|
||||
// paints its centered copy outside the row box, over the title and the
|
||||
// neighbouring rows. Measured on the live composer at seat heights that
|
||||
// force the cap, then restored for the answer gesture below. Replay only:
|
||||
// record mode must reach the recording write below, not abort on layout.
|
||||
// Squeezed card: the question and compact option rows share one scrollport,
|
||||
// while navigation and submission stay outside it. Measured on the live
|
||||
// composer at seat heights that force the cap, then restored for the answer
|
||||
// gesture below. Replay only: record mode must reach the recording write
|
||||
// below, not abort on layout.
|
||||
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
if (MODE !== 'record') {
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
const scrollStates: boolean[] = []
|
||||
for (const height of [520, 440, 380]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
await page.setViewportSize({ width: 640, height })
|
||||
const squeeze = await composer.evaluate((card) => {
|
||||
// Role/ARIA selectors, not the CSS-module class names: the built
|
||||
// client hashes those.
|
||||
const rows = [...card.querySelectorAll<HTMLElement>(
|
||||
'[role="radio"], [role="checkbox"], [aria-expanded]',
|
||||
)]
|
||||
const rows = [...card.querySelectorAll<HTMLElement>('[data-option-row]')]
|
||||
const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
|
||||
const box = row.getBoundingClientRect()
|
||||
const inner = child.getBoundingClientRect()
|
||||
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
|
||||
})))
|
||||
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const title = card.querySelector('h2')
|
||||
return {
|
||||
rows: rows.length,
|
||||
spill: Math.max(...spill),
|
||||
// Wrapped option text is what overflows a collapsed row, and a
|
||||
// scrolling list proves the seat is genuinely capped. Without both,
|
||||
// the spill assertion would hold vacuously.
|
||||
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
|
||||
scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
|
||||
titleInScrollport: list?.contains(title) === true,
|
||||
}
|
||||
})
|
||||
expect(squeeze.rows).toBeGreaterThan(0)
|
||||
expect(squeeze.wrappedRows).toBeGreaterThan(0)
|
||||
expect(squeeze.scrolls).toBe(true)
|
||||
for (const description of await composer.locator('[data-option-description]').all()) {
|
||||
const copy = await description.evaluate(readCopy)
|
||||
expect(copy.lines).toBeLessThanOrEqual(2)
|
||||
expect(copy.clipped).toBe(copy.overflows)
|
||||
expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
|
||||
}
|
||||
scrollStates.push(squeeze.scrolls)
|
||||
expect(squeeze.titleInScrollport).toBe(true)
|
||||
expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true)
|
||||
// Sub-pixel tolerance: every row's copy stays inside its border box.
|
||||
expect(squeeze.spill).toBeLessThan(0.6)
|
||||
}
|
||||
await page.setViewportSize(original)
|
||||
expect(scrollStates).toContain(true)
|
||||
await page.setViewportSize(originalViewport)
|
||||
}
|
||||
|
||||
// The narrowest supported card clips Blue's description to one line; its
|
||||
// toggle expands the text in place without choosing Blue.
|
||||
await page.setViewportSize({ width: 390, height: 700 })
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
const blueText = composer.locator('[data-option-description]').first()
|
||||
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
// A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it.
|
||||
expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true })
|
||||
expect(await composer.locator('textarea').evaluate((field) => {
|
||||
const row = field.closest('[class*="customRow"]')
|
||||
if (!(row instanceof HTMLElement)) return false
|
||||
const box = row.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2)
|
||||
return hit !== null && row.contains(hit)
|
||||
})).toBe(true)
|
||||
await blueToggle.click()
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
await blueToggle.click()
|
||||
// A collapsed copy's scroll timeline reports the overflow from the next frame.
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize(originalViewport)
|
||||
|
||||
// Multi-line custom answer: the field is a textarea whose hidden mirror
|
||||
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
|
||||
// field instead of scrolling one line, and Shift+Enter breaks the line
|
||||
@@ -415,7 +481,6 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await custom.fill('')
|
||||
}
|
||||
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
await blue.click()
|
||||
await custom.fill('Include accessibility notes')
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
@@ -536,6 +601,436 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => {
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'reasoning',
|
||||
header: '确认思考等级',
|
||||
question: '选择 HF provider 的思考等级?',
|
||||
options: [
|
||||
{ label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' },
|
||||
{ label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' },
|
||||
{ label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' },
|
||||
{ label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let completed = false
|
||||
try {
|
||||
await page.setViewportSize({ width: 1328, height: 720 })
|
||||
await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor()
|
||||
expect(await composer.getByRole('heading').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0)
|
||||
const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => {
|
||||
const label = row.querySelector<HTMLElement>('[data-option-line] > span:first-child')
|
||||
const description = row.querySelector<HTMLElement>('[data-option-description]')
|
||||
if (label === null || description === null) return null
|
||||
const style = getComputedStyle(description)
|
||||
return {
|
||||
sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
maskImage: style.maskImage,
|
||||
}
|
||||
}))
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows).toEqual(Array.from({ length: 4 }, () => ({
|
||||
sameLine: true, fontSize: '13px', lineHeight: '20px', maskImage: 'none',
|
||||
})))
|
||||
await composer.getByRole('radio', { name: '中(medium)' }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } })
|
||||
completed = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
|
||||
if (completed) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ')
|
||||
const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.'
|
||||
const description = Array.from({ length: 48 }, (_, index) =>
|
||||
`Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ')
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'release',
|
||||
header: 'Release decision',
|
||||
question,
|
||||
detail,
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'Explicit install', description },
|
||||
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
|
||||
{ label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' },
|
||||
],
|
||||
}, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let bodyCompleted = false
|
||||
try {
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const heading = composer.getByRole('heading', { name: question })
|
||||
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
|
||||
has: page.getByRole('checkbox', { name: label, exact: true }),
|
||||
}).locator('[data-option-description]')
|
||||
const toggleFor = async (copy: Locator) =>
|
||||
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
|
||||
const readMetrics = () => composer.evaluate((card) => {
|
||||
const scroll = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const title = card.querySelector<HTMLElement>('h2')
|
||||
const submit = [...card.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Next')
|
||||
if (scroll === null || title === null || submit === undefined) return null
|
||||
const submitBox = submit.getBoundingClientRect()
|
||||
return {
|
||||
footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
|
||||
scrolls: scroll.scrollHeight > scroll.clientHeight,
|
||||
titleInScrollport: scroll.contains(title),
|
||||
}
|
||||
})
|
||||
// Cover common desktop sizes plus the CSS-pixel equivalents of a
|
||||
// progressively zoomed 640x420 window without changing browser-global zoom.
|
||||
const scrollStates: boolean[] = []
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 900, height: 600 },
|
||||
{ width: 640, height: 420 },
|
||||
{ width: 512, height: 336 },
|
||||
{ width: 427, height: 280 },
|
||||
{ width: 390, height: 700 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport)
|
||||
await expect.poll(readMetrics).not.toBeNull()
|
||||
const metrics = await readMetrics()
|
||||
expect(metrics?.footerFits).toBe(true)
|
||||
expect(metrics?.titleInScrollport).toBe(true)
|
||||
scrollStates.push(metrics?.scrolls ?? false)
|
||||
// Past its three-line budget the question keeps two lines; past its
|
||||
// two-line budget a description keeps one. Copy within budget shows in full.
|
||||
await expect.poll(() => heading.evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Keep the default edge').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
for (const copy of await composer.locator('[data-option-description]').all()) {
|
||||
const drawn = await copy.evaluate(readCopy)
|
||||
expect(drawn.clipped).toBe(drawn.overflows)
|
||||
expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined)
|
||||
expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2)
|
||||
}
|
||||
if (viewport.width === 512) {
|
||||
expect(await descriptionOf('Move the package').evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
|
||||
}
|
||||
const textMetrics = await composer.locator('[data-option-description]').evaluateAll(nodes => nodes.map((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { fontSize: style.fontSize, lineHeight: style.lineHeight,
|
||||
fitsViewport: rect.left >= 0 && rect.right <= innerWidth }
|
||||
}))
|
||||
expect(textMetrics).toEqual(Array.from({ length: 3 }, () => ({
|
||||
fontSize: '13px', lineHeight: '20px', fitsViewport: true,
|
||||
})))
|
||||
}
|
||||
expect(scrollStates).toContain(false)
|
||||
expect(scrollStates).toContain(true)
|
||||
|
||||
// A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail.
|
||||
expect(await composer.getByText(detail, { exact: true }).evaluate((text) => {
|
||||
const box = text.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + 8, box.top + 3)
|
||||
return hit !== null && text.contains(hit)
|
||||
})).toBe(true)
|
||||
|
||||
// The question expands in place; its choices move down inside the
|
||||
// scrollport while navigation and submission stay reachable.
|
||||
const questionToggle = await toggleFor(heading)
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Expand')
|
||||
expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id'))
|
||||
await questionToggle.click()
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse')
|
||||
const expandedQuestion = await heading.evaluate(readCopy)
|
||||
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedQuestion.lines).toBeGreaterThan(3)
|
||||
expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true)
|
||||
expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true })
|
||||
await questionToggle.click()
|
||||
await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
const detailText = composer.getByText(detail, { exact: true })
|
||||
expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true)
|
||||
await detailText.scrollIntoViewIfNeeded()
|
||||
expect(await detailText.isVisible()).toBe(true)
|
||||
expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true)
|
||||
|
||||
// A description expands in place without choosing its option, and its
|
||||
// toggle stays described by the text it controls.
|
||||
const explicit = composer.getByRole('checkbox', { name: 'Explicit install' })
|
||||
const explicitText = descriptionOf('Explicit install')
|
||||
const descriptionToggle = await toggleFor(explicitText)
|
||||
const descriptionId = await explicitText.getAttribute('id')
|
||||
expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand')
|
||||
await descriptionToggle.click()
|
||||
expect(await explicit.getAttribute('aria-checked')).toBe('false')
|
||||
const expandedDescription = await explicitText.evaluate(readCopy)
|
||||
expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedDescription.lines).toBeGreaterThan(2)
|
||||
expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true)
|
||||
expect(await explicitText.evaluate((text) => {
|
||||
const row = text.closest('[data-option-row]') as HTMLElement
|
||||
return [...row.querySelectorAll<HTMLElement>('*')].every(child =>
|
||||
child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5)
|
||||
})).toBe(true)
|
||||
|
||||
// The toggle's icon keeps non-text contrast on the card in both themes.
|
||||
const palette: string[] = []
|
||||
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark)
|
||||
const colors = await descriptionToggle.evaluate((toggle) => {
|
||||
const card = toggle.closest('section')
|
||||
return card === null ? null : { text: getComputedStyle(toggle).color, surface: getComputedStyle(card).backgroundColor }
|
||||
})
|
||||
if (colors === null) throw new Error('The question card is unavailable')
|
||||
const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? []
|
||||
const luminance = (color: string) => channels(color).reduce((total, channel, index) => {
|
||||
const normalized = channel / 255
|
||||
const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||
return total + linear * [0.2126, 0.7152, 0.0722][index]!
|
||||
}, 0)
|
||||
const foreground = luminance(colors.text)
|
||||
const background = luminance(colors.surface)
|
||||
expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05))
|
||||
.toBeGreaterThanOrEqual(3)
|
||||
palette.push(colors.surface)
|
||||
}
|
||||
expect(palette[0]).not.toBe(palette[1])
|
||||
} finally {
|
||||
await page.evaluate((previous) => {
|
||||
if (previous === null) document.body.removeAttribute('data-ds-dark-theme')
|
||||
else document.body.setAttribute('data-ds-dark-theme', previous)
|
||||
}, originalDarkTheme)
|
||||
}
|
||||
await descriptionToggle.click()
|
||||
await expect.poll(() => explicitText.evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await composer.getByRole('checkbox', { name: 'Explicit install' }).click()
|
||||
const answer = composer.getByRole('textbox')
|
||||
await answer.fill('Keep the migration steps explicit.')
|
||||
await answer.press('Enter')
|
||||
await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor()
|
||||
expect(await answer.evaluate(node => document.activeElement === node)).toBe(true)
|
||||
await page.keyboard.type('Release owner')
|
||||
expect(await answer.inputValue()).toBe('Release owner')
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({
|
||||
value: {
|
||||
answers: [{
|
||||
id: 'release',
|
||||
selected: ['Explicit install'],
|
||||
custom: 'Keep the migration steps explicit.',
|
||||
}, { id: 'followup', selected: [], custom: 'Release owner' }],
|
||||
},
|
||||
})
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
bodyCompleted = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
|
||||
if (bodyCompleted) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
// Sized for the chat-width drag range: the question takes three lines or
|
||||
// fewer in the widest chat column and more in the narrowest, and the staged
|
||||
// description takes two lines or fewer and more.
|
||||
const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?`
|
||||
const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.'
|
||||
const explicit = Array.from({ length: 24 }, (_, index) =>
|
||||
`Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ')
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'rollout',
|
||||
header: 'Rollout decision',
|
||||
question,
|
||||
options: [
|
||||
{ label: 'Explicit install', description: explicit },
|
||||
{ label: 'Stage the rollout', description: staged },
|
||||
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let bodyCompleted = false
|
||||
try {
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const heading = composer.getByRole('heading', { name: question })
|
||||
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
|
||||
has: page.getByRole('radio', { name: label, exact: true }),
|
||||
}).locator('[data-option-description]')
|
||||
const toggleFor = async (copy: Locator) =>
|
||||
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
|
||||
// Settled layout: no AppFrame column transition in flight, then every
|
||||
// collapsed copy drawn as its own overflow decides, the expanded copy
|
||||
// still expanded, and the actions reachable.
|
||||
const settle = async () => {
|
||||
await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false)
|
||||
const read = async () => {
|
||||
const card = await composer.locator('section').evaluate((section) => {
|
||||
const scroll = section.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const submit = [...section.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Submit')
|
||||
const submitBox = submit?.getBoundingClientRect()
|
||||
return {
|
||||
width: Math.round(section.getBoundingClientRect().width),
|
||||
actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
|
||||
titleInScrollport: scroll?.contains(section.querySelector('h2')) === true,
|
||||
}
|
||||
})
|
||||
return {
|
||||
card,
|
||||
question: await heading.evaluate(readCopy),
|
||||
explicit: await descriptionOf('Explicit install').evaluate(readCopy),
|
||||
staged: await descriptionOf('Stage the rollout').evaluate(readCopy),
|
||||
short: await descriptionOf('Keep the default edge').evaluate(readCopy),
|
||||
}
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const state = await read()
|
||||
const decided = (copy: Awaited<ReturnType<typeof read>>['question'], budget: number) =>
|
||||
copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined)
|
||||
&& copy.lines <= (copy.clipped ? budget - 1 : budget)
|
||||
return state.card.actionsReachable && state.card.titleInScrollport
|
||||
&& decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2)
|
||||
&& !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse'
|
||||
}).toBe(true)
|
||||
return await read()
|
||||
}
|
||||
// The right transcript handle widens the content column by twice its
|
||||
// outward travel. The composer seat covers the handle's lower part, so
|
||||
// the gesture grabs it beside the transcript.
|
||||
const dragChatWidth = async (travel: number) => {
|
||||
const box = await page.locator('[data-width-handle="right"]').boundingBox()
|
||||
if (box === null) throw new Error('The chat width handle is not rendered')
|
||||
const x = box.x + box.width / 2
|
||||
const y = box.y + 120
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await page.mouse.move(x + travel, y, { steps: 8 })
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded copy is the user's choice and survives every width change below.
|
||||
const explicitToggle = await toggleFor(descriptionOf('Explicit install'))
|
||||
await explicitToggle.click()
|
||||
expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await dragChatWidth(-400)
|
||||
const narrow = await settle()
|
||||
expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await dragChatWidth(600)
|
||||
const wide = await settle()
|
||||
expect(wide.card.width).toBeGreaterThan(narrow.card.width)
|
||||
expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined })
|
||||
expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
|
||||
// The right sidebar narrows the conversation column, and collapsing it restores the wide state.
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
const withSidebar = await settle()
|
||||
expect(withSidebar.card.width).toBeLessThan(wide.card.width)
|
||||
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
|
||||
expect(await settle()).toEqual(wide)
|
||||
|
||||
// A smaller window clamps the dragged width; restoring the window restores it.
|
||||
await page.setViewportSize({ width: 900, height: 700 })
|
||||
const smallWindow = await settle()
|
||||
expect(smallWindow.card.width).toBeLessThan(wide.card.width)
|
||||
expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
expect(await settle()).toEqual(wide)
|
||||
|
||||
await explicitToggle.click()
|
||||
await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } })
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
bodyCompleted = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
// The dragged width is a persisted preference; dropping it and resizing republishes the default.
|
||||
const cleanup = await Promise.allSettled([
|
||||
outcome,
|
||||
page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') })
|
||||
.then(() => page.setViewportSize(original)),
|
||||
])
|
||||
if (bodyCompleted) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
|
||||
// real composer; the wire, Remote gateway, agent loop, and the REAL bash tool (echo
|
||||
// in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless)
|
||||
@@ -249,7 +250,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
setup: agentCtx => probe.ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const result = await probe.ctx.tools.execute({
|
||||
const result = await executeToolFixture(probe.ctx, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
callId: ToolCallId('web-url-probe'),
|
||||
name: 'bash',
|
||||
|
||||
@@ -384,13 +384,6 @@ export interface LaunchOptions {
|
||||
paceMs?: number
|
||||
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
|
||||
replayContextWindow?: number
|
||||
/**
|
||||
* Tool presentation mode patched onto the shipped `tools` row (`code`
|
||||
* collapses the wire to run_code + the SDK prompt section). Omit for the
|
||||
* yml default. The PTC runtime row is always in the tree, so no extra
|
||||
* insertion is needed.
|
||||
*/
|
||||
toolsMode?: 'native' | 'ptc'
|
||||
/**
|
||||
* Keep the shipped DeepSeek adapter mounted while masking the process
|
||||
* environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
|
||||
@@ -689,7 +682,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
{ id: 'agent-preset-registry', config: { default: options.agentPresets.default } },
|
||||
{ insert: (options.agentPresets.definitions ?? []).map(config => ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) },
|
||||
],
|
||||
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
|
||||
...options.deepSeekSearch === undefined
|
||||
? []
|
||||
: [{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Keyless assembled-Web evidence for conversational Schedule delivery. */
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
@@ -354,7 +355,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
await workspace.attachSession(afterHandle.agent.id)
|
||||
const afterCreated = await scaffold.ctx.tools.execute({
|
||||
const afterCreated = await executeToolFixture(scaffold.ctx, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
callId: ToolCallId('schedule-after-create'),
|
||||
name: 'schedule_create',
|
||||
@@ -420,7 +421,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
})
|
||||
await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id })
|
||||
await workspace.attachSession(everyHandle.agent.id)
|
||||
const everyListed = await scaffold.ctx.tools.execute({
|
||||
const everyListed = await executeToolFixture(scaffold.ctx, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
callId: ToolCallId('schedule-every-list'),
|
||||
name: 'schedule_list',
|
||||
@@ -1181,7 +1182,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
})
|
||||
const time = clock.format(new Date(Date.now() + 3_600_000))
|
||||
const prompt = 'Inspect the daily calendar rule'
|
||||
const result = await scaffold.ctx.tools.execute({
|
||||
const result = await executeToolFixture(scaffold.ctx, {
|
||||
signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'),
|
||||
name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent,
|
||||
})
|
||||
|
||||
@@ -366,7 +366,12 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('stores Permission as the default for future sessions without changing an existing session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
const createSession = async (id: string) => {
|
||||
const handle = await scaffold.ctx.agents.create({ sessionId: SessionId(id) })
|
||||
onTestFinished(() => handle.dispose())
|
||||
return handle.agent.session
|
||||
}
|
||||
const existing = await createSession('settings-permission-before')
|
||||
expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
@@ -386,7 +391,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
|
||||
const created = await createSession('settings-permission-after')
|
||||
expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'read-only' }],
|
||||
['sandbox/mode', { mode: 'read-only' }],
|
||||
@@ -403,7 +408,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await dialog.getByRole('button', { name: '完全权限' }).waitFor({ timeout: 10_000 })
|
||||
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
|
||||
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
|
||||
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
|
||||
const confirmed = await createSession('settings-permission-confirmed')
|
||||
expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
// Boots the shipped Web composition over the built dist this lane already uses
|
||||
// and asserts its catalog, defaults, Loader lifecycle, and one complete Auto
|
||||
// producer-to-tool path. Browser scenarios in this lane own visual behavior.
|
||||
@@ -679,7 +680,7 @@ it('lets a preset producer reach the background-job registry', async () => {
|
||||
// `tool-bash` is a preset row and `tasks` is a host registry; the producer
|
||||
// resolves it with `ctx.get`, so a registry hidden behind a preset realm
|
||||
// fails here — with every task control still listed in the catalog above.
|
||||
const started = await ctx.tools.execute({
|
||||
const started = await executeModelToolFixture(ctx, {
|
||||
signal,
|
||||
callId: ToolCallId('shipped-bash-background'),
|
||||
name: 'bash',
|
||||
@@ -701,7 +702,7 @@ it('lets a preset producer reach the background-job registry', async () => {
|
||||
|
||||
// The controller reads what the producer started: same registry, one
|
||||
// owner. A per-preset registry would list nothing here even on success.
|
||||
const listed = await ctx.tools.execute({
|
||||
const listed = await executeToolFixture(ctx, {
|
||||
signal,
|
||||
callId: ToolCallId('shipped-task-list'),
|
||||
name: 'job_list',
|
||||
@@ -714,7 +715,7 @@ it('lets a preset producer reach the background-job registry', async () => {
|
||||
|
||||
// The full round trip: the output a host-plane producer wrote is collected
|
||||
// through a preset-plane control, which is the linkage the realm severed.
|
||||
const collected = await ctx.tools.execute({
|
||||
const collected = await executeToolFixture(ctx, {
|
||||
signal,
|
||||
callId: ToolCallId('shipped-task-output'),
|
||||
name: 'job_output',
|
||||
@@ -1036,7 +1037,7 @@ it('rolls back a failed shipped Auto initialization before publishing or interce
|
||||
// contribution performs no review, so success proves the failed shipped
|
||||
// integration left no pre-execute listener behind. It is not supported
|
||||
// Auto execution behavior.
|
||||
const result = await ctx.tools.execute({
|
||||
const result = await executeToolFixture(ctx, {
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('shipped-auto-init-rollback-write'),
|
||||
name: 'write',
|
||||
|
||||
@@ -1103,6 +1103,58 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('keeps a dragged tab when a middle click is chorded with the primary button', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-drag'))
|
||||
const column = await resetSidebar(page)
|
||||
await proseChip(page).click()
|
||||
await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME })
|
||||
const from = await pointIn(sample, 0.25, 0.5)
|
||||
const to = await pointIn(column.getByRole('tab').filter({ hasText: 'Files' }), 0.25, 0.5)
|
||||
await page.mouse.move(from.x, from.y)
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await page.mouse.move(to.x, to.y, { steps: 8 })
|
||||
await column.locator('[data-dockkit-caret="0"]').waitFor({ state: 'visible' })
|
||||
await page.mouse.down({ button: 'middle' })
|
||||
await page.mouse.up({ button: 'middle' })
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Files'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('closes background and active tabs with the middle button and keeps the last guide', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-close'))
|
||||
const column = await resetSidebar(page)
|
||||
await proseChip(page).click()
|
||||
await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME })
|
||||
const files = column.getByRole('tab').filter({ hasText: 'Files' })
|
||||
const selected = () => column.locator('[role="tab"][aria-selected="true"] [data-dockkit-tab-title]').allTextContents()
|
||||
await expect.poll(selected).toEqual([SAMPLE_NAME])
|
||||
|
||||
await files.click({ button: 'middle' })
|
||||
await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME])
|
||||
expect(await selected()).toEqual([SAMPLE_NAME])
|
||||
await column.locator('[data-dockkit-add-tab]').click()
|
||||
await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
|
||||
await sample.click()
|
||||
await expect.poll(selected).toEqual([SAMPLE_NAME])
|
||||
await sample.click({ button: 'middle' })
|
||||
await expect.poll(() => tabTitles(column)).toEqual(['Start'])
|
||||
expect(await selected()).toEqual(['Start'])
|
||||
|
||||
await column.getByRole('tab').click({ button: 'middle' })
|
||||
expect(await tabTitles(column)).toEqual(['Start'])
|
||||
expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
|
||||
expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves the open surface after a reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
|
||||
const frame = page.locator('[class*="frame"]').first()
|
||||
|
||||
@@ -603,7 +603,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('DSH_TOOLS_MODE=ptc collapses the provider wire tools to run_code with the SDK prompt section', async () => {
|
||||
it.each(['native', 'ptc'] as const)('selects the %s executor through profile overlays and ignores the retired environment selector', async (mode) => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-'))
|
||||
|
||||
@@ -632,14 +632,17 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'],
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web',
|
||||
...mode === 'ptc' ? ['--patch', join(REPO_ROOT, 'apps/cli/config/examples/ptc.yml')] : [],
|
||||
'--no-open', '--port', '0',
|
||||
],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-ptc',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_TOOLS_MODE: 'ptc',
|
||||
DSH_TOOLS_MODE: mode === 'native' ? 'ptc' : 'native',
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(workspace, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
@@ -662,9 +665,15 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
|
||||
}),
|
||||
])
|
||||
expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code'])
|
||||
expect(captured.system).toContain('## Writing code for run_code')
|
||||
expect(captured.system).toContain('declare const tools')
|
||||
if (mode === 'ptc') {
|
||||
expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code'])
|
||||
expect(captured.system).toContain('## Writing code for run_code')
|
||||
expect(captured.system).toContain('declare const tools')
|
||||
} else {
|
||||
expect(captured.tools?.map(tool => tool.name)).not.toContain('run_code')
|
||||
expect(captured.tools?.length).toBeGreaterThan(0)
|
||||
expect(captured.system).not.toContain('## Writing code for run_code')
|
||||
}
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
|
||||
@@ -224,6 +224,8 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const requestInputs: string[][] = []
|
||||
let firstRequestSignal: AbortSignal | undefined
|
||||
const questionStarted = Promise.withResolvers<undefined>()
|
||||
const releaseQuestion = Promise.withResolvers<undefined>()
|
||||
const FOLLOWUP = 'Also acknowledge this second message.'
|
||||
const RESPONSE = "Great, let's move forward. BANANA!"
|
||||
@@ -239,9 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
message.role === 'user'
|
||||
? message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: []))
|
||||
if (requestInputs.length === 1) firstRequestSignal = options.signal
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') {
|
||||
questionStarted.resolve(undefined)
|
||||
await releaseQuestion.promise
|
||||
}
|
||||
}
|
||||
}, { prepend: true })
|
||||
browser = await chromium.launch()
|
||||
@@ -271,11 +277,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await questionStarted.promise
|
||||
await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1)
|
||||
await input.fill(STEER)
|
||||
await input.press('Meta+Enter')
|
||||
await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Stop generating', exact: true }).click()
|
||||
await expect.poll(() => firstRequestSignal?.aborted).toBe(true)
|
||||
releaseQuestion.resolve(undefined)
|
||||
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1)
|
||||
expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
|
||||
import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:*",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: e89802f066e4955d
|
||||
zh: ea2fb4bef94e086d
|
||||
/deepseek-harness-architecture/turn-flow:
|
||||
en: 4151c0b69f563f4e
|
||||
zh: 32c62b6326446c10
|
||||
en: 1a79a70b62c04cdb
|
||||
zh: c86d765e21430405
|
||||
/deepseek-harness-architecture/session-log:
|
||||
en: 8a7f19b95d426c99
|
||||
zh: d5bb0538404e20ea
|
||||
|
||||
@@ -106,9 +106,11 @@ turn/end
|
||||
|
||||
`turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
|
||||
|
||||
Factory setup follows SessionStore entry and `session/created`, preceding `agent/created`; closing events commit before removal.
|
||||
|
||||
One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
|
||||
|
||||
The loop snapshots Session model selection before assembly and shares it through `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain that selection, and omitted reasoning effort uses adapter defaults. `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
|
||||
Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
|
||||
|
||||
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
|
||||
|
||||
@@ -116,6 +118,8 @@ Failed steps [record missing tool results](../packages/core/agent-loop/README.md
|
||||
|
||||
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
|
||||
|
||||
[`ctx.tools.execute()`](subsystems/tools.md) receives raw batches and concurrency limits.
|
||||
|
||||
## Session log
|
||||
|
||||
The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)).
|
||||
|
||||
@@ -110,9 +110,11 @@ turn/end
|
||||
|
||||
`turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
|
||||
|
||||
工厂 setup 在 Session 进入 SessionStore 并完成 `session/created` 之后、`agent/created` 之前运行;收尾事件在移除之前提交。
|
||||
|
||||
输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
|
||||
|
||||
循环在组装前快照 Session 模型选择,并通过 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection` 传递该选择;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
|
||||
循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
|
||||
|
||||
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
|
||||
|
||||
@@ -120,6 +122,8 @@ turn/end
|
||||
|
||||
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
|
||||
|
||||
[`ctx.tools.execute()`](subsystems/tools.zh.md) 接收原始调用批次和并发上限。
|
||||
|
||||
## 会话日志
|
||||
|
||||
会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史。每个 `assistant/message` 都嵌入产生其组装内容的精确紧凑带时间 stream;`assistant/attempt` 保留已到达 settlement 的失败、重试、取消与 stream error attempt,且不添加模型历史。fork、恢复、transcript(文本记录)、遥测与持久化都从这些持久 settlement 派生,实时 UI 增量则来自 `agent/assistant-stream`;如果进程在 settlement 前硬中断,则不会留下持久 attempt stream(见[决策](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md))。
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 753b3da3abc6aeea
|
||||
zh: aab02ec16c3f744b
|
||||
en: f2546ac3454d220d
|
||||
zh: 642a473c0405e81f
|
||||
@@ -166,7 +166,7 @@ flowchart LR
|
||||
pkg_tools["tools"]
|
||||
pkg_tool_terminal["tool-terminal"]
|
||||
pkg_tool_web["tool-web"]
|
||||
svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"]
|
||||
svc_tools["ctx.tools<br/>Scoped tool registry and executor selection"]
|
||||
pkg_tool_ask_user["tool-ask-user"]
|
||||
pkg_tool_cordis["tool-cordis"]
|
||||
pkg_tool_skill["tool-skill"]
|
||||
@@ -235,6 +235,7 @@ flowchart LR
|
||||
svc_ptcRuntime["ctx.ptcRuntime<br/>PTC execution seam"]
|
||||
pkg_ptc_runtime_node["ptc-runtime-node"]
|
||||
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
|
||||
pkg_tool_executor_ptc["tool-executor-ptc"]
|
||||
pkg_workflow_ptc["workflow-ptc"]
|
||||
pkg_fs["fs"]
|
||||
svc_fs["ctx.fs<br/>Filesystem provider seam"]
|
||||
@@ -516,7 +517,7 @@ flowchart LR
|
||||
svc_pluginMarket --> pkg_plugin_market
|
||||
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
|
||||
svc_profileContext --> pkg_plugin_manager
|
||||
svc_ptcRuntime --> pkg_tools
|
||||
svc_ptcRuntime --> pkg_tool_executor_ptc
|
||||
svc_ptcRuntime --> pkg_workflow_ptc
|
||||
svc_remoteAccess --> pkg_api_gateway
|
||||
svc_remoteAccess --> pkg_api_session_controller
|
||||
@@ -677,7 +678,7 @@ flowchart LR
|
||||
| `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
|
||||
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
|
||||
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
|
||||
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
|
||||
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch. |
|
||||
| `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
|
||||
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
|
||||
| `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | Eagerly mounts YAML-declared preset revisions, binds Agents and cold readers to scoped contributions, and retains retired revisions until their last user releases them. |
|
||||
@@ -699,7 +700,7 @@ flowchart LR
|
||||
| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. |
|
||||
| `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
|
||||
| `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. |
|
||||
| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration. |
|
||||
| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration. |
|
||||
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. |
|
||||
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. |
|
||||
|
||||
@@ -168,7 +168,7 @@ flowchart LR
|
||||
pkg_tools["tools"]
|
||||
pkg_tool_terminal["tool-terminal"]
|
||||
pkg_tool_web["tool-web"]
|
||||
svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"]
|
||||
svc_tools["ctx.tools<br/>Scoped tool registry and executor selection"]
|
||||
pkg_tool_ask_user["tool-ask-user"]
|
||||
pkg_tool_cordis["tool-cordis"]
|
||||
pkg_tool_skill["tool-skill"]
|
||||
@@ -237,6 +237,7 @@ flowchart LR
|
||||
svc_ptcRuntime["ctx.ptcRuntime<br/>PTC execution seam"]
|
||||
pkg_ptc_runtime_node["ptc-runtime-node"]
|
||||
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
|
||||
pkg_tool_executor_ptc["tool-executor-ptc"]
|
||||
pkg_workflow_ptc["workflow-ptc"]
|
||||
pkg_fs["fs"]
|
||||
svc_fs["ctx.fs<br/>Filesystem provider seam"]
|
||||
@@ -518,7 +519,7 @@ flowchart LR
|
||||
svc_pluginMarket --> pkg_plugin_market
|
||||
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
|
||||
svc_profileContext --> pkg_plugin_manager
|
||||
svc_ptcRuntime --> pkg_tools
|
||||
svc_ptcRuntime --> pkg_tool_executor_ptc
|
||||
svc_ptcRuntime --> pkg_workflow_ptc
|
||||
svc_remoteAccess --> pkg_api_gateway
|
||||
svc_remoteAccess --> pkg_api_session_controller
|
||||
@@ -679,7 +680,7 @@ flowchart LR
|
||||
| `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 |
|
||||
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 |
|
||||
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 |
|
||||
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 |
|
||||
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力和作用域执行器。原生与 PTC 提供者负责工具描述及完整原始调用批次;它们可选用共享流水线处理工具策略和分发。 |
|
||||
| `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 |
|
||||
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 |
|
||||
| `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | 立即挂载 YAML 声明的 preset 版本,把 Agent 和冷读取绑定到作用域内的贡献,并保留已替换的版本,直到最后一个使用者释放它。 |
|
||||
@@ -701,7 +702,7 @@ flowchart LR
|
||||
| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 统一保存部署默认模式和工作区根目录;只有沙箱执行器和提供方读取该服务(工具层使用它同时导出的纯 `sandbox/mode` 折叠区)。两类强制执行组件都读取该服务,因此 bash 与 fs 不会限制到不同的根目录。 |
|
||||
| `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | 一次性权限决策通过 `approval/request` waterfall(瀑布式事件)分派;回答方是监听器(即 ACP 为自身 agent 提供的桥接),没有回答方时以 `unavailable` 关闭失败。 |
|
||||
| `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | 面向用户的预设表(`workspace-write`/`danger-full-access`),将沙箱模式与审批策略选项组合在一起;一次切换会写入一个 `permission/preset` 事件,并贯通到两个选项事件。 |
|
||||
| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tools 负责 PTC 呈现,workflow-ptc 负责工作流编排。 |
|
||||
| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tool-executor-ptc 负责 PTC 呈现,workflow-ptc 负责工作流编排。 |
|
||||
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 |
|
||||
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 |
|
||||
|
||||
+32
-47
@@ -96,7 +96,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
|
||||
- `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:251`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Agent-loop plugin configuration. */
|
||||
@@ -162,11 +162,13 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-agent-tool-presentation`
|
||||
|
||||
- `inject`: `tools`
|
||||
- `refs`: [`ToolPresentationMode`](subsystems/tools.md)
|
||||
- `source`: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
|
||||
- `source`: [`packages/core/agent-tool-presentation/src/index.ts:25`](../packages/core/agent-tool-presentation/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. */
|
||||
/**
|
||||
* Plugin config.
|
||||
* @deprecated Select the native or PTC executor plugin directly instead of configuring a mode.
|
||||
*/
|
||||
export interface Config {
|
||||
/**
|
||||
* The form this agent's model sees. `native` sends every visible schema,
|
||||
@@ -175,7 +177,7 @@ export interface Config {
|
||||
* without this row already gets, so an omitted value would mean the row was
|
||||
* composed for nothing.
|
||||
*/
|
||||
mode: ToolPresentationMode
|
||||
mode: 'native' | 'ptc'
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-agent-tool-presentation -->
|
||||
@@ -1706,7 +1708,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2394,9 +2396,9 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-permission-presets`
|
||||
|
||||
- `inject`: `shell` · `approval` · `sessions` · `sessionProjections`
|
||||
- `inject`: `shell` · `approval` · `sessions` · `agents` · `sessionProjections`
|
||||
- `refs`: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) · `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
|
||||
- `source`: [`packages/interaction/permission-presets/src/index.ts:153`](../packages/interaction/permission-presets/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The {@link PermissionPresetService} config: preset table and composition default. */
|
||||
@@ -2890,7 +2892,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-session-log-export`
|
||||
|
||||
- `inject`: `commands` · `connection`
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
@@ -3270,7 +3272,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-spill-policy`
|
||||
|
||||
- `inject`: `tools`
|
||||
- `source`: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts)
|
||||
- `source`: [`packages/spill/spill-policy/src/index.ts:26`](../packages/spill/spill-policy/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Optional result-retention budget. */
|
||||
@@ -3849,6 +3851,23 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-bash-persistent -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-executor-ptc -->
|
||||
<a id="deepseek-aidsh-tool-executor-ptc"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-executor-ptc`
|
||||
|
||||
- `inject`: `tools` · `systemPrompt` · `ptcRuntime`
|
||||
- `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Program execution limits. */
|
||||
export interface Config {
|
||||
/** Maximum overlapping concurrency-safe program subcalls. */
|
||||
maxParallelSubCalls: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-executor-ptc -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-fs -->
|
||||
<a id="deepseek-aidsh-tool-fs"></a>
|
||||
|
||||
@@ -4320,43 +4339,6 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-workspace-dependencies -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tools -->
|
||||
<a id="deepseek-aidsh-tools"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tools`
|
||||
|
||||
- `inject`: `systemPrompt`
|
||||
- `source`: [`packages/core/tools/src/index.ts:735`](../packages/core/tools/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Model presentation. `native` (default) sends every visible schema; `ptc`
|
||||
* sends only `run_code` plus a generated SDK prompt and collapses the
|
||||
* executor to the same surface (a model-direct call may only name
|
||||
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool).
|
||||
* PTC mode requires a `ctx.ptcRuntime` whose `language`
|
||||
* has a registered SDK renderer (TypeScript or Python) and fails prompt
|
||||
* assembly when it is absent or has no renderer. Under `ptc`, native names
|
||||
* in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
* Concurrency cap for a `run_code` program's overlapping sub-calls
|
||||
* (default 10, the loop scheduler's own default). Sub-calls follow the
|
||||
* native scheduling contract — only calls whose tools classify
|
||||
* concurrency-safe overlap; exclusive calls form barriers — so `1`
|
||||
* restores strictly serial dispatch. Must be a positive integer.
|
||||
*/
|
||||
maxParallelSubCalls?: number
|
||||
}
|
||||
|
||||
/** How the registry presents its tools to the model (see {@link Config.mode}). */
|
||||
export type ToolPresentationMode = 'native' | 'ptc'
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tools -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-typert-loader -->
|
||||
<a id="deepseek-aidsh-typert-loader"></a>
|
||||
|
||||
@@ -4801,9 +4783,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-webhook` | `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` | [`packages/experimental/webhook/src/index.ts`](../packages/experimental/webhook/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-workspace` | `storageDomain` · `sessionPersistence` | [`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts) |
|
||||
@@ -4900,6 +4884,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
| `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-testkit` | — | [`packages/test-support/tool-testkit/src/index.ts`](../packages/test-support/tool-testkit/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
|
||||
|
||||
+32
-47
@@ -98,7 +98,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
|
||||
- `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts)
|
||||
- `source`: [`packages/core/agent-loop/src/index.ts:251`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Agent-loop plugin configuration. */
|
||||
@@ -164,11 +164,13 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-agent-tool-presentation`
|
||||
|
||||
- `inject`: `tools`
|
||||
- `refs`: [`ToolPresentationMode`](subsystems/tools.zh.md)
|
||||
- `source`: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
|
||||
- `source`: [`packages/core/agent-tool-presentation/src/index.ts:25`](../packages/core/agent-tool-presentation/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. */
|
||||
/**
|
||||
* Plugin config.
|
||||
* @deprecated Select the native or PTC executor plugin directly instead of configuring a mode.
|
||||
*/
|
||||
export interface Config {
|
||||
/**
|
||||
* The form this agent's model sees. `native` sends every visible schema,
|
||||
@@ -177,7 +179,7 @@ export interface Config {
|
||||
* without this row already gets, so an omitted value would mean the row was
|
||||
* composed for nothing.
|
||||
*/
|
||||
mode: ToolPresentationMode
|
||||
mode: 'native' | 'ptc'
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-agent-tool-presentation -->
|
||||
@@ -1708,7 +1710,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2396,9 +2398,9 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-permission-presets`
|
||||
|
||||
- `inject`: `shell` · `approval` · `sessions` · `sessionProjections`
|
||||
- `inject`: `shell` · `approval` · `sessions` · `agents` · `sessionProjections`
|
||||
- `refs`: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
|
||||
- `source`: [`packages/interaction/permission-presets/src/index.ts:153`](../packages/interaction/permission-presets/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The {@link PermissionPresetService} config: preset table and composition default. */
|
||||
@@ -2892,7 +2894,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-session-log-export`
|
||||
|
||||
- `inject`: `commands` · `connection`
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
@@ -3272,7 +3274,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-spill-policy`
|
||||
|
||||
- `inject`: `tools`
|
||||
- `source`: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts)
|
||||
- `source`: [`packages/spill/spill-policy/src/index.ts:26`](../packages/spill/spill-policy/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Optional result-retention budget. */
|
||||
@@ -3851,6 +3853,23 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-bash-persistent -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-executor-ptc -->
|
||||
<a id="deepseek-aidsh-tool-executor-ptc"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-executor-ptc`
|
||||
|
||||
- `inject`: `tools` · `systemPrompt` · `ptcRuntime`
|
||||
- `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Program execution limits. */
|
||||
export interface Config {
|
||||
/** Maximum overlapping concurrency-safe program subcalls. */
|
||||
maxParallelSubCalls: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-executor-ptc -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-fs -->
|
||||
<a id="deepseek-aidsh-tool-fs"></a>
|
||||
|
||||
@@ -4322,43 +4341,6 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-workspace-dependencies -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tools -->
|
||||
<a id="deepseek-aidsh-tools"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tools`
|
||||
|
||||
- `inject`: `systemPrompt`
|
||||
- `source`: [`packages/core/tools/src/index.ts:735`](../packages/core/tools/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Model presentation. `native` (default) sends every visible schema; `ptc`
|
||||
* sends only `run_code` plus a generated SDK prompt and collapses the
|
||||
* executor to the same surface (a model-direct call may only name
|
||||
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool).
|
||||
* PTC mode requires a `ctx.ptcRuntime` whose `language`
|
||||
* has a registered SDK renderer (TypeScript or Python) and fails prompt
|
||||
* assembly when it is absent or has no renderer. Under `ptc`, native names
|
||||
* in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
* Concurrency cap for a `run_code` program's overlapping sub-calls
|
||||
* (default 10, the loop scheduler's own default). Sub-calls follow the
|
||||
* native scheduling contract — only calls whose tools classify
|
||||
* concurrency-safe overlap; exclusive calls form barriers — so `1`
|
||||
* restores strictly serial dispatch. Must be a positive integer.
|
||||
*/
|
||||
maxParallelSubCalls?: number
|
||||
}
|
||||
|
||||
/** How the registry presents its tools to the model (see {@link Config.mode}). */
|
||||
export type ToolPresentationMode = 'native' | 'ptc'
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tools -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-typert-loader -->
|
||||
<a id="deepseek-aidsh-typert-loader"></a>
|
||||
|
||||
@@ -4803,9 +4785,11 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-webhook` | `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` | [`packages/experimental/webhook/src/index.ts`](../packages/experimental/webhook/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-workspace` | `storageDomain` · `sessionPersistence` | [`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts) |
|
||||
@@ -4902,6 +4886,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-testkit` | — | [`packages/test-support/tool-testkit/src/index.ts`](../packages/test-support/tool-testkit/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
|
||||
|
||||
@@ -37,12 +37,13 @@ export function apply(ctx: Context) {
|
||||
// the model. ToolCallId brands the correlation id a provider would issue.
|
||||
void (async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: brandString<ToolCallId>('demo-1'),
|
||||
name: 'greet',
|
||||
arguments: { name: 'Cordis' },
|
||||
calls: [{
|
||||
type: 'tool-call', id: brandString<ToolCallId>('demo-1'),
|
||||
name: 'greet', arguments: JSON.stringify({ name: 'Cordis' }),
|
||||
}],
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
console.log('tool replied:', JSON.stringify(result.content))
|
||||
console.log('tool replied:', JSON.stringify(result.results[0]?.result.content))
|
||||
})()
|
||||
}
|
||||
```
|
||||
|
||||
Loaded 100 of 813 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user