mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge branch 'master' into fix/windows-installer-extract-diagnostics
This commit is contained in:
commit
4544874d62
766 files changed
+1924
-14117
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
|
||||
2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1
|
||||
2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720
|
||||
2026-08-31-released-session-format-migrations.md: 00672af920cf8369022b4274c1c50552f5407a7e
|
||||
2026-08-31-released-session-format-migrations.zh.md: 9404d8f454b7c17be69ae064c405c4dcc0794f88
|
||||
+2
@@ -89,6 +89,8 @@ Each released codec creates a row decoder with explicit `strict` or `recoverable
|
||||
|
||||
The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`.
|
||||
|
||||
The V3-to-V4 edge applies the same bounded restart repair to logs written directly as V3. Keeping this repair in the incoming edge preserves [native V4 lifecycle validation](2026-09-17-native-v4-read-validation.md); accepting repeated starts there would admit producer corruption in new logs. Insertions require local-reference and inherited-cut remapping, while captured generations retain their original coordinates. The [format specification](../../../../packages/session/session-format-v3-to-v4/README.md#sequence-references) owns the exact evidence and fields.
|
||||
|
||||
The catalog exposes one `createRestore()` operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains `SessionFormatUnsupportedMigrationError`; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization.
|
||||
|
||||
### JSONL integration
|
||||
|
||||
+2
@@ -89,6 +89,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。
|
||||
|
||||
V3-to-V4 边对直接写成 V3 的日志应用同样的有限 restart 修复。将修复保留在迁入边,可以维持[原生 V4 生命周期校验](2026-09-17-native-v4-read-validation.zh.md);若在那里接受重复 start,新日志也会允许生产者损坏。插入事件要求重映射本地引用与继承截点,而捕获代际保留原坐标。[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#sequence-references)负责定义具体证据和字段。
|
||||
|
||||
Catalog 为 production、Worker、fixture 与 replay 暴露同一个 `createRestore()`。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 `SessionFormatUnsupportedMigrationError`;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。
|
||||
|
||||
### JSONL 串联
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
|
||||
2026-09-14-deepseek-account-login.md: 65405b293cdea14a231d8d700589f9c3e7ef6db7
|
||||
2026-09-14-deepseek-account-login.zh.md: 0aca011826006a0fb8f0557464187b73da377238
|
||||
2026-09-14-deepseek-account-login.md: 9d9853617a3b7e288084c93eac4e150f3c180999
|
||||
2026-09-14-deepseek-account-login.zh.md: d9cdbed25b0ae79caa4c61b310383cc52d8b586a
|
||||
@@ -46,7 +46,7 @@ Profile and recharge-wallet queries use independent getProfile and getBalance op
|
||||
|
||||
Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
|
||||
|
||||
The Host sends login_source (desktop or web) in auth_init so Platform can choose the completion experience. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
|
||||
The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请
|
||||
|
||||
开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
|
||||
|
||||
Host 在 auth_init 中发送 login_source(desktop 或 web),供 Platform 选择完成页交互。Web 失败时关闭授权标签页,原标签页接收 Host 状态,不使用 Web UI 返回地址。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
|
||||
macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。
|
||||
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md
|
||||
2026-09-20-device-local-shortcut-preferences.md: 3da3d3a29c5fd068f3498de78c2de84d3f30fe45
|
||||
2026-09-20-device-local-shortcut-preferences.zh.md: 3c5420e8708bf168a61d3d60e6a6a2add60a9455
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Keep shortcut preferences device-local and commit before activation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-device-local-shortcut-preferences.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Keyboard bindings depend on the receiving device's operating system and browser restrictions. A Host can serve several devices, while a local save can fail after a user records a new combination. Activating an unpersisted binding makes the interface disagree with the next application launch. Rewriting unreadable or future-version preferences can destroy choices an older client cannot interpret.
|
||||
|
||||
## Decision
|
||||
|
||||
[The shortcut service](../../../../packages/client/shortcuts/README.md) stores only versioned overrides per runtime/platform. Web owns origin-local browser storage; Electron main owns `userData/keybindings.json`, independently of Harness home. Both adapters share validation and transaction logic. Successful persistence publishes one accepted configuration from which command matching, keycaps, and native interception derive.
|
||||
|
||||
Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Restore All clears only the accepted current profile and cannot overwrite unreadable or future-version data.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Host settings.** A server's operating system and settings scope do not identify the visitor's device or browser restrictions. Host storage would also couple remote visitors' preferences.
|
||||
|
||||
**Optimistic activation.** Updating bindings before persistence makes a failed save appear successful and leaves restart behavior inconsistent with the visible controls.
|
||||
|
||||
**Cross-tab atomic merging.** localStorage has no compare-and-swap transaction. Web rereads before each operation and rejects known stale drafts, but accepts last-writer-wins for simultaneous writes. Electron main serializes edits and rejects stale revisions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Devices and Web/Desktop do not synchronize bindings automatically. Repairing unreadable preferences requires correcting the stored document; application edits preserve those bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and refusal to replace unreadable data; platform keyboard acceptance remains separate from persistence correctness.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# Agent Note: 快捷键偏好按设备保存,提交后才激活
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-device-local-shortcut-preferences.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
快捷键取决于接收输入设备的操作系统和浏览器限制。一个 Host 可服务多个设备,而用户录入新组合后,本地保存仍可能失败。激活未持久化的绑定会使界面与下一次启动不一致。重写不可读或未来版本的偏好可能破坏旧客户端无法解释的选择。
|
||||
|
||||
## 决策
|
||||
|
||||
[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。
|
||||
|
||||
未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。全部恢复只清除已接受的当前平台覆盖,不会覆盖不可读或未来版本的数据。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**Host 设置。** 服务端操作系统和设置范围不能标识访问者设备或浏览器限制,Host 存储还会耦合远程访问者的偏好。
|
||||
|
||||
**乐观激活。** 在持久化之前更新绑定会让保存失败看起来像成功,并使重启行为与可见控件不一致。
|
||||
|
||||
**跨标签页原子合并。** localStorage 没有比较并交换事务。Web 每次操作前重新读取并拒绝已知过期草稿,但同时写入采用最后成功写入生效。Electron main 串行处理编辑并拒绝旧 revision。
|
||||
|
||||
## 影响
|
||||
|
||||
不同设备以及 Web/Desktop 不自动同步绑定。修复不可读偏好需要更正存储文档;应用内编辑保留这些原始内容。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md
|
||||
2026-09-21-desktop-page-close-shortcuts.md: dc976806445f9b78c3dbad83d4cd9f3b0b2f4799
|
||||
2026-09-21-desktop-page-close-shortcuts.zh.md: 6ef60c084fe15cdc0f608992a700084cbf81b4c1
|
||||
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Desktop close shortcuts follow page focus
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-desktop-page-close-shortcuts.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [shortcut PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) gives the close command two targets: the focused right-sidebar page, or the Desktop window when no page can close. Electron's native close role bypasses the page owner. On Windows, closing the last window also quits the Desktop instance and stops its Host tasks, so this fallback is a lifecycle choice rather than a menu-label change.
|
||||
|
||||
## Decision
|
||||
|
||||
The sidebar page owner resolves close against live focus and captures the target's Session, pane, tab occurrence, and navigation revision. It closes that page through its resource cleanup handler, or collapses the sole docked guide. Stale sidebar targets do not fall back to another page or the window. Without a closeable page, Desktop requests native window closure; Web has no window fallback.
|
||||
|
||||
The macOS File menu routes Close Page or Window through the same owner and displays the accepted single-key binding. Native closure requires the current configuration revision, a focused and enabled product window, and inactive shortcut recording. The existing window lifecycle applies without a shortcut-specific confirmation: macOS keeps the application alive after its last window closes; Windows and Linux quit and stop the Host.
|
||||
|
||||
This decision owns File-menu and close behavior in place of the close-role choice in [the standard macOS menu note](../bug-fix/2026-09-16-desktop-window-menus.md). That note retains the rationale for native Window and application hide commands. [Shortcut preference persistence](2026-09-20-device-local-shortcut-preferences.md) owns accepted bindings and revision publication.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use Electron's native close role.** It closes the window without asking the focused page owner and cannot preserve page cleanup or the accepted custom binding's routing.
|
||||
|
||||
**Disable the Windows window fallback.** This avoids a task-stopping close shortcut but omits the PRD's explicit Desktop fallback. The product keeps native window-close semantics; users who need the application to remain running can minimize it.
|
||||
|
||||
## Consequences
|
||||
|
||||
Close behaves consistently across the shortcut and macOS File menu. On Windows and Linux, pressing it outside a closeable sidebar page can stop active tasks by quitting the instance. The command does not add a background Host lifetime or a task-aware shutdown confirmation. Desktop keyboard tests cover revision and focus guards; sidebar focus tests cover captured page identity and stale targets; startup tests cover the platform menu declarations.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Desktop 关闭快捷键跟随页面焦点
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-21-desktop-page-close-shortcuts.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[快捷键 PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) 为关闭命令规定了两类目标:获得焦点的右侧栏页面,或没有可关闭页面时的 Desktop 窗口。Electron 原生 close role 绕过页面 owner。Windows 关闭最后一个窗口还会退出 Desktop 实例并停止其 Host 任务,因此该回退属于生命周期选择,不只是菜单标签变更。
|
||||
|
||||
## 决策
|
||||
|
||||
侧栏页面 owner 根据实时焦点解析关闭操作,并捕获目标的 Session、窗格、tab occurrence 和导航 revision。它通过资源清理处理器关闭该页面,或收起唯一的停靠引导页。过期侧栏目标不会回退到其他页面或窗口。没有可关闭页面时,Desktop 请求原生窗口关闭;Web 没有窗口回退。
|
||||
|
||||
macOS File 菜单通过同一个 owner 路由“关闭页面或窗口”,并显示已接受的单键绑定。原生关闭要求配置 revision 仍为当前值、产品窗口已聚焦且启用,并且没有在录制快捷键。现有窗口生命周期直接生效,不增加快捷键专用确认:macOS 最后一个窗口关闭后保留应用;Windows 和 Linux 退出并停止 Host。
|
||||
|
||||
本决策接管[标准 macOS 菜单记录](../bug-fix/2026-09-16-desktop-window-menus.zh.md)中 close role 选择所涉及的 File 菜单和关闭行为。该记录保留原生 Window 与应用隐藏命令的理由。[快捷键偏好持久化](2026-09-20-device-local-shortcut-preferences.zh.md)负责已接受绑定及 revision 发布。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**使用 Electron 原生 close role。** 它不询问获得焦点的页面 owner 就关闭窗口,无法保留页面清理和已接受自定义绑定的路由。
|
||||
|
||||
**禁用 Windows 窗口回退。** 这能避免关闭快捷键停止任务,但会遗漏 PRD 明确要求的 Desktop 回退。产品保留原生关窗语义;需要应用继续运行时,用户可以最小化窗口。
|
||||
|
||||
## 影响
|
||||
|
||||
快捷键与 macOS File 菜单的关闭行为一致。在 Windows 和 Linux 上,焦点不在可关闭侧栏页面时按下该快捷键,可能因退出实例而停止活动任务。该命令不增加后台 Host 生命周期或感知任务状态的退出确认。Desktop 键盘测试覆盖 revision 和焦点保护;侧栏焦点测试覆盖捕获的页面身份和过期目标;启动测试覆盖各平台菜单声明。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md
|
||||
2026-09-16-desktop-window-menus.md: 34eff83707440719182f4ebe61aa552919bd44ee
|
||||
2026-09-16-desktop-window-menus.zh.md: c3cb3e5d89497ae15210c034c0077fc8019e5688
|
||||
2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47
|
||||
2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253
|
||||
@@ -10,9 +10,9 @@ The Desktop shell replaces Electron's default application menu with a custom tem
|
||||
|
||||
## Decision
|
||||
|
||||
On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior.
|
||||
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
|
||||
|
||||
Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers.
|
||||
Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -22,13 +22,13 @@ Electron supplies English defaults for the Window and Edit roles. Explicit label
|
||||
|
||||
**Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it.
|
||||
|
||||
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application. The menu-restoration task did not request this quit path; the page-close decision explicitly specifies the Desktop fallback.
|
||||
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path.
|
||||
|
||||
**Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays.
|
||||
|
||||
## Consequences
|
||||
|
||||
macOS retains native Window and application hide commands through four menu roles. Window and Edit retain Electron’s default English labels; application commands use the Desktop locale.
|
||||
macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
|
||||
|
||||
## 决策
|
||||
|
||||
macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。
|
||||
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。
|
||||
Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -22,13 +22,13 @@ Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆
|
||||
|
||||
**在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。
|
||||
|
||||
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用。恢复菜单的任务没有要求这条退出路径;页面关闭决策明确规定了 Desktop 回退。
|
||||
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。
|
||||
|
||||
**让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。
|
||||
|
||||
## 影响
|
||||
|
||||
macOS 通过四个菜单 role 保留原生 Window 和应用隐藏命令。Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
|
||||
macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: c90e8162f785bb0aa8e25b755fa278725d1dd034
|
||||
README.zh.md: d78fc7ada24826f317877162ecf26cad21b519e3
|
||||
README.md: adb17f4cd3615e407baa7993bec17f8ad70bb1a9
|
||||
README.zh.md: 1410e08eec9bbf5c271ea74900e9f1e4562697b0
|
||||
@@ -22,10 +22,6 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh
|
||||
|
||||
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
|
||||
|
||||
Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics.
|
||||
|
||||
The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice.
|
||||
|
||||
The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release.
|
||||
|
||||
### Bundled workspace dependencies
|
||||
@@ -68,9 +64,9 @@ The product UI retains Web actions, including "Open In..." through the shared au
|
||||
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
|
||||
On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus.
|
||||
On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
|
||||
|
||||
### Runtime and plugin activation
|
||||
|
||||
@@ -90,6 +86,7 @@ Native dialog details include at most 1,200 UTF-16 code units and eight diagnost
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
|
||||
## Develop
|
||||
|
||||
The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
|
||||
|
||||
@@ -22,10 +22,6 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie
|
||||
|
||||
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
|
||||
|
||||
快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。
|
||||
|
||||
macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。
|
||||
|
||||
macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。
|
||||
|
||||
### 内置工作区依赖
|
||||
@@ -68,9 +64,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
|
||||
|
||||
macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
|
||||
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
### 运行时与插件激活
|
||||
|
||||
@@ -90,6 +86,7 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
|
||||
## 开发
|
||||
|
||||
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
|
||||
@@ -378,7 +375,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
|
||||
|
||||
登录和策略请求共用内存 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 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
|
||||
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -69,8 +69,6 @@
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"vite": "^6.0.0",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^"
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ async function launchElectron(): Promise<void> {
|
||||
const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222)
|
||||
const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230)
|
||||
const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home'))
|
||||
const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data'))
|
||||
const userData = join(DEVELOPMENT_ROOT, 'electron-user-data')
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
@@ -72,7 +72,6 @@ async function launchElectron(): Promise<void> {
|
||||
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
|
||||
}
|
||||
console.log(`desktop development: DSH_HOME=${home}`)
|
||||
console.log(`desktop development: userData=${userData}`)
|
||||
console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`)
|
||||
if (process.platform === 'darwin') {
|
||||
const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData,
|
||||
|
||||
@@ -9,7 +9,6 @@ interface GuestLease {
|
||||
readonly partition: string
|
||||
attached: boolean
|
||||
guest?: WebContents
|
||||
releaseInput?: () => void
|
||||
}
|
||||
|
||||
/** Owns workspace storage partitions independently from individual tab guests. */
|
||||
@@ -52,7 +51,6 @@ export class DesktopBrowserGuests {
|
||||
const lease = this.leases.get(key)
|
||||
if (lease === undefined) return
|
||||
if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window')
|
||||
lease.releaseInput?.()
|
||||
this.leases.delete(key)
|
||||
const guest = lease.guest
|
||||
if (guest !== undefined && !guest.isDestroyed()) {
|
||||
@@ -65,9 +63,8 @@ export class DesktopBrowserGuests {
|
||||
/**
|
||||
* Install attachment checks before the application document can create a webview.
|
||||
* @param window - primary application window.
|
||||
* @param attachInput - attaches native input after guest ownership is verified and returns its disposer.
|
||||
*/
|
||||
bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void {
|
||||
bind(window: BrowserWindow): void {
|
||||
const owner = window.webContents
|
||||
owner.on('will-attach-webview', (event, preferences, params) => {
|
||||
const id = typeof params.src === 'string' && params.src.startsWith('about:blank#')
|
||||
@@ -105,8 +102,7 @@ export class DesktopBrowserGuests {
|
||||
}
|
||||
lease.guest = guest
|
||||
attachedLease = id
|
||||
lease.releaseInput = attachInput(guest, id)
|
||||
guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) })
|
||||
guest.once('destroyed', () => { this.leases.delete(id) })
|
||||
})
|
||||
guest.setWindowOpenHandler(({ url, postBody }) => {
|
||||
const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease)
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
/** Typed preload operations exposed only by the Electron shell. */
|
||||
|
||||
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
|
||||
/** IPC channel names kept private to the desktop application bundle. */
|
||||
export const DESKTOP_IPC = {
|
||||
shortcutsInput: 'dsh-desktop:shortcuts-input',
|
||||
shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window',
|
||||
shortcutsGet: 'dsh-desktop:shortcuts-get',
|
||||
shortcutsEdit: 'dsh-desktop:shortcuts-edit',
|
||||
shortcutsChanged: 'dsh-desktop:shortcuts-changed',
|
||||
shortcutsRecording: 'dsh-desktop:shortcuts-recording',
|
||||
boot: 'dsh-desktop:boot',
|
||||
enterWorkspace: 'dsh-desktop:enter-workspace',
|
||||
bootFailed: 'dsh-desktop:boot-failed',
|
||||
@@ -69,8 +62,6 @@ export interface DesktopUpdatePresentation {
|
||||
export interface DshDesktopProductApi {
|
||||
readonly protocolVersion: 1
|
||||
readonly browser: DesktopBrowserBridge
|
||||
readonly keyboard: DesktopKeyboardApi
|
||||
readonly shortcuts: DesktopShortcutsApi
|
||||
readonly updates: {
|
||||
status(): Promise<DesktopUpdatePresentation>
|
||||
open(): Promise<void>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { ShortcutPersistence } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { ShortcutConfigSnapshot, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
|
||||
/**
|
||||
* Open the device configuration with atomic replacement.
|
||||
* @param userData - Electron-owned userData directory, never a Renderer-supplied path.
|
||||
* @param platform - local input platform.
|
||||
* @param publish - updates the native binding index and trusted product page after commit.
|
||||
* @returns the single-writer transaction coordinator.
|
||||
*/
|
||||
export function desktopKeybindings(userData: string, platform: ShortcutPlatform,
|
||||
publish: (snapshot: ShortcutConfigSnapshot) => void): ShortcutPersistence {
|
||||
const path = join(userData, 'keybindings.json')
|
||||
return new ShortcutPersistence({
|
||||
read: async () => {
|
||||
try { return await readFile(path, 'utf8') } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }),
|
||||
}, 'desktop', platform, false, publish)
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */
|
||||
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions, type WebContents } from 'electron'
|
||||
import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import { desktopKeybindings } from './keybindings.ts'
|
||||
import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
|
||||
|
||||
/**
|
||||
* Install application-owned configuration handlers and attach each product window's input lifecycle.
|
||||
* @param getWindow - current product window.
|
||||
* @param userData - Electron-resolved device preference directory.
|
||||
* @param platform - local device platform.
|
||||
* @param updateMenu - rebuild the application menu when the close accelerator or availability changes.
|
||||
* @returns menu construction, editor key delivery, window attachment, and teardown operations.
|
||||
*/
|
||||
export function installDesktopShortcuts(
|
||||
getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void,
|
||||
): {
|
||||
fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions
|
||||
/**
|
||||
* Send a native Edit action to the editor without matching user shortcuts.
|
||||
* @param keyCode - edit key.
|
||||
* @param modifiers - edit modifiers.
|
||||
*/
|
||||
sendEditingKey(keyCode: string, modifiers: Array<'control'>): void
|
||||
attach(window: BrowserWindow): void
|
||||
/**
|
||||
* Intercept an approved browser guest's keys until its lease ends.
|
||||
* @param window - owning product window.
|
||||
* @param guest - approved browser contents.
|
||||
* @param name - main-issued lease used as the webview element name.
|
||||
* @returns idempotent listener disposer.
|
||||
*/
|
||||
attachGuest(window: BrowserWindow, guest: WebContents, name: DesktopBrowserLeaseId): () => void
|
||||
dispose(): void
|
||||
} {
|
||||
let definitions: readonly ShortcutDefinition[] = []
|
||||
let recording = false
|
||||
let revision: ShortcutRevision | undefined
|
||||
let closeBinding: NormalizedBinding | null = null
|
||||
let editingInput: BrowserWindow['webContents'] | undefined
|
||||
const scopedDesktop = platform === 'windows' || platform === 'macos'
|
||||
const disposers = new Set<() => void>()
|
||||
const guestInputs = new Map<WebContents, { window: BrowserWindow; reset(): void }>()
|
||||
const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria
|
||||
?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1')
|
||||
const sendMenuClose = (): void => {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled()
|
||||
|| revision === undefined || recording) return
|
||||
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision })
|
||||
}
|
||||
let keys = new Set<string>()
|
||||
const publish = (snapshot: ShortcutConfigSnapshot): void => {
|
||||
const wasEnabled = revision !== undefined
|
||||
const previousAccelerator = closeAccelerator()
|
||||
revision = definitions.length === 0 || snapshot.status === 'loading' ? undefined : snapshot.revision
|
||||
const rows = snapshot.status === 'loading' ? [] : effectiveShortcuts(definitions, snapshot.document, 'desktop', platform)
|
||||
keys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null && row.conflicts.length === 0
|
||||
? [bindingKey(row.binding)] : []))
|
||||
const close = rows.find(row => row.id === 'page.close')
|
||||
closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null
|
||||
if (wasEnabled !== (revision !== undefined) || previousAccelerator !== closeAccelerator()) updateMenu()
|
||||
const window = getWindow()
|
||||
if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) {
|
||||
window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot)
|
||||
}
|
||||
}
|
||||
const persistence = desktopKeybindings(userData, platform, publish)
|
||||
const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed() || event.sender !== window.webContents
|
||||
|| event.senderFrame !== window.webContents.mainFrame) throw new Error('desktop shortcuts: rejected sender')
|
||||
assertDesktopSender(event, ['app'])
|
||||
return window
|
||||
}
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsGet, async (event, input: unknown) => {
|
||||
assertSender(event)
|
||||
definitions = parseShortcutDefinitions(input)
|
||||
persistence.setDefinitions(definitions)
|
||||
return persistence.readCurrent()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, revision: unknown) => {
|
||||
assertSender(event)
|
||||
if (typeof revision !== 'string') throw new Error('desktop shortcuts: invalid revision')
|
||||
return persistence.edit(parseShortcutEdit(input), revision as ShortcutRevision)
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => {
|
||||
const window = assertSender(event)
|
||||
if (typeof active !== 'boolean') throw new Error('desktop shortcuts: invalid recording state')
|
||||
recording = active
|
||||
window.webContents.setIgnoreMenuShortcuts(active)
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => {
|
||||
const window = assertSender(event)
|
||||
if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()) return
|
||||
window.close()
|
||||
})
|
||||
function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void {
|
||||
let deadKey = false
|
||||
const held = new Set<string>()
|
||||
const consumed = new Map<string, 'press' | 'repeat'>()
|
||||
let inputFrame: typeof contents.focusedFrame = null
|
||||
let inputRevision: ShortcutRevision | undefined
|
||||
const resetInput = (): void => {
|
||||
deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined
|
||||
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
|
||||
}
|
||||
if (guestName !== undefined) guestInputs.set(contents, { window, reset: resetInput })
|
||||
const resetWindow = (): void => {
|
||||
resetInput()
|
||||
for (const guest of guestInputs.values()) if (guest.window === window) guest.reset()
|
||||
}
|
||||
const clear = (): void => {
|
||||
resetInput()
|
||||
if (guestName !== undefined) return
|
||||
definitions = []; keys.clear(); recording = false
|
||||
persistence.setDefinitions(null)
|
||||
}
|
||||
const navigation = (event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>): void => {
|
||||
if (event.isMainFrame && !event.isSameDocument) clear()
|
||||
}
|
||||
const beforeInput = (event: Electron.Event, input: Input): void => {
|
||||
if (editingInput === contents) {
|
||||
held.clear()
|
||||
consumed.clear()
|
||||
contents.setIgnoreMenuShortcuts(true)
|
||||
return
|
||||
}
|
||||
if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === undefined
|
||||
|| (guestName !== undefined && !contents.isFocused())) {
|
||||
contents.setIgnoreMenuShortcuts(false)
|
||||
held.clear()
|
||||
consumed.clear()
|
||||
return
|
||||
}
|
||||
const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier])
|
||||
const key = bindingKey({ code: input.code, modifiers })
|
||||
const match = keys.has(key)
|
||||
const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+')
|
||||
&& input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '')
|
||||
contents.setIgnoreMenuShortcuts(recording || match || menuMatch)
|
||||
const frame = contents.focusedFrame
|
||||
const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr')
|
||||
if (input.type === 'keyDown') deadKey = input.key === 'Dead'
|
||||
if (recording || composing || frame === null) { held.clear(); consumed.clear(); return }
|
||||
let binding: NormalizedBinding = { code: input.code, modifiers }
|
||||
let priority = false
|
||||
if (scopedDesktop) {
|
||||
if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision }
|
||||
const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code)
|
||||
if (input.type === 'keyUp') {
|
||||
// A chord's first key reached the renderer, so its release must reach the same input handlers.
|
||||
if (consumed.get(input.code) === 'press') event.preventDefault()
|
||||
consumed.delete(input.code)
|
||||
held.delete(input.code)
|
||||
if (modifierKey) held.clear()
|
||||
return
|
||||
}
|
||||
if (!input.isAutoRepeat) consumed.delete(input.code)
|
||||
if (modifierKey) { held.clear(); return }
|
||||
if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return }
|
||||
if (input.isAutoRepeat && !held.has(input.code) && !match) return
|
||||
held.add(input.code)
|
||||
const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)]
|
||||
codes.sort()
|
||||
const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers }
|
||||
priority = keys.has(key)
|
||||
if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true }
|
||||
}
|
||||
const main = guestName === undefined && frame === contents.mainFrame
|
||||
if (!priority && (main || !match)) return
|
||||
event.preventDefault()
|
||||
if (input.type !== 'keyDown') return
|
||||
if (scopedDesktop) {
|
||||
if (!input.isAutoRepeat) {
|
||||
if (binding.secondCode !== undefined) {
|
||||
consumed.set(binding.code, 'repeat')
|
||||
consumed.set(binding.secondCode, 'repeat')
|
||||
}
|
||||
consumed.set(input.code, 'press')
|
||||
}
|
||||
// Electron can omit both keyups after interception; completed presses cannot seed another chord.
|
||||
held.clear()
|
||||
}
|
||||
let embedding = frame
|
||||
while (guestName === undefined && !main && embedding.parent !== null && embedding.parent !== contents.mainFrame) {
|
||||
embedding = embedding.parent
|
||||
}
|
||||
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: guestName === undefined ? main ? 'keyboard' : 'iframe' : 'webview',
|
||||
revision, frameName: guestName ?? (main ? '' : embedding.name),
|
||||
code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }),
|
||||
repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta })
|
||||
}
|
||||
const dispose = (): void => {
|
||||
contents.off('did-start-navigation', navigation)
|
||||
contents.off('before-input-event', beforeInput)
|
||||
contents.off('blur', resetInput)
|
||||
contents.off('destroyed', dispose)
|
||||
if (guestName === undefined) { window.off('blur', resetWindow); window.off('closed', closed) }
|
||||
disposers.delete(dispose)
|
||||
guestInputs.delete(contents)
|
||||
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
|
||||
}
|
||||
const closed = (): void => { clear(); dispose() }
|
||||
contents.on('did-start-navigation', navigation)
|
||||
contents.on('before-input-event', beforeInput)
|
||||
contents.on('blur', resetInput)
|
||||
contents.once('destroyed', dispose)
|
||||
if (guestName === undefined) { window.on('closed', closed); window.on('blur', resetWindow) }
|
||||
disposers.add(dispose)
|
||||
return dispose
|
||||
}
|
||||
|
||||
return {
|
||||
sendEditingKey(keyCode, modifiers) {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed()) return
|
||||
const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0]
|
||||
?? window.webContents
|
||||
contents.focus()
|
||||
const previous = editingInput
|
||||
editingInput = contents
|
||||
try {
|
||||
// Electron emits before-input-event synchronously for these editor-owned keys.
|
||||
contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
||||
contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
||||
} finally {
|
||||
editingInput = previous
|
||||
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(recording)
|
||||
}
|
||||
},
|
||||
fileMenu: (labels) => {
|
||||
const accelerator = closeAccelerator()
|
||||
return { label: labels.fileMenu, submenu: [{
|
||||
id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined,
|
||||
...accelerator === undefined ? {} : { accelerator },
|
||||
click: sendMenuClose,
|
||||
}] }
|
||||
},
|
||||
attach(window) { attachInput(window, window.webContents) },
|
||||
attachGuest: attachInput,
|
||||
dispose() {
|
||||
for (const dispose of disposers) dispose()
|
||||
persistence.dispose()
|
||||
for (const channel of [DESKTOP_IPC.shortcutsGet,
|
||||
DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
export const en = {
|
||||
application: 'Application',
|
||||
fileMenu: 'File',
|
||||
closePage: 'Close Page or Window',
|
||||
aboutMenu: 'About DeepSeek Harness',
|
||||
hideApplication: 'Hide DeepSeek Harness',
|
||||
hideOtherApplications: 'Hide Others',
|
||||
@@ -131,8 +129,6 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
|
||||
|
||||
export const zh = {
|
||||
application: '应用',
|
||||
fileMenu: '文件',
|
||||
closePage: '关闭页面或窗口',
|
||||
aboutMenu: '关于 DeepSeek Harness',
|
||||
hideApplication: '隐藏 DeepSeek Harness',
|
||||
hideOtherApplications: '隐藏其他',
|
||||
|
||||
+11
-14
@@ -47,7 +47,6 @@ import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
||||
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
||||
import { readDesktopRuntime } from './runtime-tree.ts'
|
||||
import { DesktopBrowserGuests } from './browser-guests.ts'
|
||||
import { installDesktopShortcuts } from './keyboard.ts'
|
||||
|
||||
let focusPrimaryWindow = (): void => {}
|
||||
let stopForRecovery = async (): Promise<void> => {}
|
||||
@@ -512,9 +511,6 @@ async function main(): Promise<void> {
|
||||
|
||||
installDesktopDirectoryPicker(() => mainWindow)
|
||||
installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents)
|
||||
const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'),
|
||||
process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() })
|
||||
app.on('will-quit', () => { shortcuts.dispose() })
|
||||
|
||||
ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
@@ -596,7 +592,7 @@ async function main(): Promise<void> {
|
||||
locale = current
|
||||
platformView.notifyLocaleChanged()
|
||||
windowsLanguage = locale.id
|
||||
refreshApplicationMenu()
|
||||
installMenu()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
|
||||
assertProductSender(event)
|
||||
@@ -748,8 +744,8 @@ async function main(): Promise<void> {
|
||||
// its standard menus and application hide commands declared explicitly.
|
||||
// Keep app.name stable: Electron derives its default userData directory from it.
|
||||
const darwin = process.platform === 'darwin'
|
||||
const platformMenus = (): MenuItemConstructorOptions[] => darwin
|
||||
? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }]
|
||||
const platformMenus: MenuItemConstructorOptions[] = darwin
|
||||
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
|
||||
: [{ role: 'editMenu' }]
|
||||
const hideCommands: MenuItemConstructorOptions[] = darwin
|
||||
? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
|
||||
@@ -778,13 +774,13 @@ async function main(): Promise<void> {
|
||||
{ role: 'toggleDevTools', visible: false },
|
||||
{ role: 'toggleDevTools', visible: false, accelerator: 'F12' },
|
||||
]
|
||||
const refreshApplicationMenu = (): void => {
|
||||
const installMenu = (): void => {
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{
|
||||
label: darwin ? app.name : currentDesktopLocale().messages.application,
|
||||
submenu: [...applicationItems(), ...devToolsItems],
|
||||
}, ...platformMenus()]))
|
||||
}, ...platformMenus]))
|
||||
}
|
||||
refreshApplicationMenu()
|
||||
installMenu()
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
|
||||
@@ -800,7 +796,9 @@ async function main(): Promise<void> {
|
||||
label,
|
||||
...(accelerator === undefined ? {} : { accelerator }),
|
||||
click: () => {
|
||||
shortcuts.sendEditingKey(keyCode, modifiers)
|
||||
window.webContents.focus()
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
||||
},
|
||||
})
|
||||
const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
|
||||
@@ -836,8 +834,7 @@ async function main(): Promise<void> {
|
||||
const createMainWindow = (): BrowserWindow => {
|
||||
const window = createWindow(appPreload, false, true)
|
||||
mainWindow = window
|
||||
browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name))
|
||||
shortcuts.attach(window)
|
||||
browserGuests.bind(window)
|
||||
window.on('focus', automaticCheck)
|
||||
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
|
||||
window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
|
||||
@@ -928,7 +925,7 @@ async function main(): Promise<void> {
|
||||
if (isQuitting() || backend.state.phase !== 'ready') return
|
||||
locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages)
|
||||
windowsLanguage = locale.id
|
||||
refreshApplicationMenu()
|
||||
installMenu()
|
||||
if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) {
|
||||
await showWelcome()
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */
|
||||
|
||||
import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts'
|
||||
import { PLATFORM_IPC } from './platform-ipc.ts'
|
||||
@@ -14,37 +13,6 @@ function createProductApi(): DshDesktopProductApi {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
browser: createDesktopBrowserBridge(),
|
||||
keyboard: {
|
||||
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
|
||||
subscribe: (listener) => {
|
||||
const handle = (_event: Electron.IpcRendererEvent, input: DesktopShortcutInput): void => {
|
||||
if (input.kind === 'iframe') {
|
||||
const element = document.activeElement
|
||||
if (!(element instanceof HTMLIFrameElement) || !element.isConnected
|
||||
|| !element.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]')) return
|
||||
if (input.frameName === '' || element.name !== input.frameName) return
|
||||
}
|
||||
if (input.kind === 'webview') {
|
||||
const element = document.activeElement
|
||||
if (element?.matches('webview[data-sidebar-browser-frame]') !== true || !element.isConnected
|
||||
|| input.frameName === '' || element.getAttribute('name') !== input.frameName) return
|
||||
}
|
||||
listener(input)
|
||||
}
|
||||
ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle)
|
||||
return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsInput, handle) }
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
get: definitions => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions) as Promise<ShortcutConfigSnapshot>,
|
||||
edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise<ShortcutSaveResult>,
|
||||
recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise<void>,
|
||||
subscribe(listener) {
|
||||
const handle = (_event: Electron.IpcRendererEvent, snapshot: ShortcutConfigSnapshot): void => { listener(snapshot) }
|
||||
ipcRenderer.on(DESKTOP_IPC.shortcutsChanged, handle)
|
||||
return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsChanged, handle) }
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise<DesktopUpdatePresentation>,
|
||||
open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise<void>,
|
||||
@@ -92,7 +60,7 @@ markDocumentPlatform()
|
||||
syncWindowFullscreen()
|
||||
syncNativeTheme()
|
||||
// Main-process IPC also verifies the owning window and top frame.
|
||||
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' && process.isMainFrame ? createProductApi() : { protocolVersion: 1 })
|
||||
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 })
|
||||
|
||||
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
|
||||
contextBridge.exposeInMainWorld('__DSH_LOCALE__', {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { desktopKeybindings } from '../src/keybindings.ts'
|
||||
import type { ShortcutCommandId, ShortcutConfigSnapshot } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
|
||||
it('persists into the supplied userData, reloads null bindings, and retains original future-version bytes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-keybindings-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
let state!: ShortcutConfigSnapshot
|
||||
const first = desktopKeybindings(root, 'windows', (value) => { state = value })
|
||||
onTestFinished(() => { first.dispose() })
|
||||
const id = 'test.toggle' as ShortcutCommandId
|
||||
const definitions = [{ id, defaults: {
|
||||
'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
} }]
|
||||
first.setDefinitions(definitions)
|
||||
await first.readCurrent()
|
||||
expect((await first.edit({ type: 'set', id, binding: null }, state.revision)).status).toBe('saved')
|
||||
const path = join(root, 'keybindings.json')
|
||||
const saved: unknown = JSON.parse(await readFile(path, 'utf8'))
|
||||
expect(saved).toMatchObject({ profiles: { 'desktop:windows': { [id]: null } } })
|
||||
const second = desktopKeybindings(root, 'windows', (value) => { state = value })
|
||||
onTestFinished(() => { second.dispose() })
|
||||
second.setDefinitions(definitions)
|
||||
expect((await second.readCurrent()).document.profiles['desktop:windows']?.[id]).toBeNull()
|
||||
const original = '{"schemaVersion":7,"custom":"retain exactly"}\n'
|
||||
await writeFile(path, original)
|
||||
expect((await second.readCurrent()).error).toBe('future')
|
||||
expect((await second.edit({ type: 'reset-all' }, state.revision)).status).toBe('unreadable')
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
})
|
||||
@@ -1,679 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
|
||||
import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot,
|
||||
ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { ShortcutRegistry } from '../../../packages/client/shortcuts/src/client/registry.ts'
|
||||
import { installKeyboard } from '../../../packages/client/shortcuts/src/client/dom.ts'
|
||||
import { installNativeKeyboard } from '../../../packages/client/shortcuts/src/client/native.ts'
|
||||
import type { DesktopBrowserLeaseId, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import { DESKTOP_IPC } from '../src/ipc.ts'
|
||||
|
||||
const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() }))
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() },
|
||||
}) } }))
|
||||
const { installDesktopShortcuts } = await import('../src/keyboard.ts')
|
||||
const { DesktopBrowserGuests } = await import('../src/browser-guests.ts')
|
||||
afterEach(() => { vi.clearAllMocks() })
|
||||
|
||||
type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null }
|
||||
type ContentsFixture = EventEmitter & Pick<WebContents,
|
||||
'isDestroyed' | 'isFocused' | 'send' | 'setIgnoreMenuShortcuts' | 'focus' | 'sendInputEvent'> & {
|
||||
mainFrame: FrameFixture
|
||||
focusedFrame: FrameFixture | null
|
||||
}
|
||||
type WindowFixture = EventEmitter & Pick<BrowserWindow, 'isDestroyed' | 'isFocused' | 'isEnabled' | 'close'> & {
|
||||
webContents: ContentsFixture
|
||||
}
|
||||
type InvokeFixture = { sender: object; senderFrame: object | null }
|
||||
type KeyboardFixture = Omit<ReturnType<typeof installDesktopShortcuts>, 'attach' | 'attachGuest'> & {
|
||||
attach(window: WindowFixture): void
|
||||
attachGuest(window: WindowFixture, guest: ContentsFixture, name: DesktopBrowserLeaseId): () => void
|
||||
}
|
||||
// Electron is substituted at module load; fixtures implement the native members exercised by this installer.
|
||||
const installFixture = installDesktopShortcuts as (
|
||||
getWindow: () => WindowFixture | undefined, userData: string,
|
||||
platform: Parameters<typeof installDesktopShortcuts>[2], updateMenu: () => void,
|
||||
) => KeyboardFixture
|
||||
type GuestsFixture = {
|
||||
acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation
|
||||
release(owner: ContentsFixture, id: unknown): Promise<void>
|
||||
bind(window: WindowFixture, attachInput: (guest: ContentsFixture, name: DesktopBrowserLeaseId) => () => void): void
|
||||
}
|
||||
|
||||
function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults'] {
|
||||
return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding }
|
||||
}
|
||||
|
||||
async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
const frame: FrameFixture = { url: 'dsh-app://app/', name: '', parent: null }
|
||||
const contents = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
isDestroyed: () => false, isFocused: () => true, send: vi.fn(),
|
||||
setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: vi.fn(() => false),
|
||||
isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() })
|
||||
let current: WindowFixture | undefined = window
|
||||
const updateMenu = vi.fn()
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu)
|
||||
keyboard.attach(current)
|
||||
onTestFinished(() => { keyboard.dispose() })
|
||||
const handlers = new Map<string, (event: InvokeFixture, ...args: unknown[]) => unknown>(
|
||||
ipc.handle.mock.calls.map(([channel, handler]) => [channel, handler]))
|
||||
const event: InvokeFixture = { sender: contents, senderFrame: frame }
|
||||
const call = async <T>(channel: string, ...args: unknown[]): Promise<T> => await handlers.get(channel)!(event, ...args) as T
|
||||
const definitions: readonly ShortcutDefinition[] = [
|
||||
{ id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) },
|
||||
]
|
||||
return { keyboard, window, contents, frame, event, handlers, call, definitions, updateMenu, detach: () => { current = undefined } }
|
||||
}
|
||||
|
||||
it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => {
|
||||
const f = await fixture()
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const press = (code: string) => f.contents.emit('before-input-event', { preventDefault: vi.fn() },
|
||||
{ type: 'keyDown', modifiers: [], code, key: code.replace('Key', ''), meta: true, control: false, alt: false, shift: false })
|
||||
press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
|
||||
press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
|
||||
press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
|
||||
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, initial.revision)).status).toBe('stale')
|
||||
await f.call(DESKTOP_IPC.shortcutsRecording, true)
|
||||
press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
|
||||
f.window.isFocused.mockReturnValue(false)
|
||||
press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
|
||||
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }, 'dsh-app://app/', false, true)
|
||||
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('not-ready')
|
||||
await f.call(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale')
|
||||
})
|
||||
|
||||
it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => {
|
||||
const f = await fixture()
|
||||
const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)!
|
||||
for (const event of [{ ...f.event, sender: {} }, { ...f.event, senderFrame: null }, { ...f.event, senderFrame: {} }]) {
|
||||
await expect(get(event, f.definitions)).rejects.toThrow('rejected sender')
|
||||
}
|
||||
for (const url of ['dsh-app://shell/', 'https://example.com/', 'http://localhost/']) {
|
||||
f.frame.url = url
|
||||
await expect(get(f.event, f.definitions)).rejects.toThrow('unowned renderer')
|
||||
}
|
||||
f.frame.url = 'dsh-app://app/'
|
||||
const ready = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all', path: '/tmp' }, ready.revision)).rejects.toThrow('Invalid')
|
||||
await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, 1)).rejects.toThrow('revision')
|
||||
await expect(f.call(DESKTOP_IPC.shortcutsRecording, 'true')).rejects.toThrow('recording')
|
||||
f.window.isDestroyed.mockReturnValue(true)
|
||||
await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender')
|
||||
f.detach(); await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender')
|
||||
})
|
||||
|
||||
it('routes embedded input once, follows rebindings, and guards native window closure by revision and focus', async () => {
|
||||
const f = await fixture()
|
||||
const closeItem = () => (f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[])[0]!
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [
|
||||
{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) },
|
||||
{ id: 'page.refresh', defaults: desktopDefaults({ code: 'KeyR', modifiers: ['primary'] }) },
|
||||
])
|
||||
expect(closeItem().accelerator).toBe('Command+W')
|
||||
const preventDefault = vi.fn()
|
||||
const input = { modifiers: [], type: 'keyDown', code: 'KeyW', key: 'w', meta: true, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }
|
||||
f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'keyboard', code: 'KeyW' }))
|
||||
preventDefault.mockClear(); f.contents.send.mockClear()
|
||||
Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } })
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', revision: initial.revision, frameName: 'embedded', code: 'KeyW' }))
|
||||
f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' })
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(1)
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision)
|
||||
expect(closeItem().accelerator).toBe('Command+J')
|
||||
f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyZ', key: 'j' })
|
||||
expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
await f.call(DESKTOP_IPC.shortcutsCloseWindow, initial.revision)
|
||||
expect(f.window.close).not.toHaveBeenCalled()
|
||||
f.window.isEnabled.mockReturnValue(false)
|
||||
await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision)
|
||||
expect(f.window.close).not.toHaveBeenCalled()
|
||||
f.window.isEnabled.mockReturnValue(true)
|
||||
await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision)
|
||||
expect(f.window.close).toHaveBeenCalledTimes(1)
|
||||
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'page.close', binding: null }, saved.snapshot.revision)
|
||||
expect(closeItem().accelerator).toBeUndefined()
|
||||
f.keyboard.dispose()
|
||||
expect(f.contents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['browser.new', 'KeyT'], ['session.new', 'KeyN'],
|
||||
])('forwards Linux %s from embedded frames while leaving main-document input to the DOM', async (id, code) => {
|
||||
const f = await fixture('linux')
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id, defaults: desktopDefaults({ code, modifiers: ['primary'] }) }])
|
||||
const input = { modifiers: ['control'], type: 'keyDown', code, key: code.slice(3).toLowerCase(),
|
||||
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
|
||||
Object.assign(f.contents, { focusedFrame: { name: 'browser', parent: f.frame } })
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'iframe', frameName: 'browser', code, revision: initial.revision }))
|
||||
f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' })
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
|
||||
const cleared = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id, binding: null }, initial.revision)
|
||||
expect(cleared.status).toBe('saved')
|
||||
preventDefault.mockClear(); f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ArrowUp', 'Command+Up'], ['ArrowDown', 'Command+Down'],
|
||||
['ArrowLeft', 'Command+Left'], ['ArrowRight', 'Command+Right'],
|
||||
])('uses the Electron accelerator for a saved %s binding', async (code, accelerator) => {
|
||||
const f = await fixture()
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }])
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code, modifiers: ['primary'] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
|
||||
expect(menu[0]?.accelerator).toBe(accelerator)
|
||||
})
|
||||
|
||||
it('refreshes the native menu only when its accelerator or availability changes', async () => {
|
||||
const f = await fixture()
|
||||
const close = { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }
|
||||
expect(f.updateMenu).not.toHaveBeenCalled()
|
||||
await f.call(DESKTOP_IPC.shortcutsGet, [close])
|
||||
expect(f.updateMenu).toHaveBeenCalledOnce()
|
||||
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
|
||||
const registered = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [close, ...f.definitions])
|
||||
const edited = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, registered.revision)
|
||||
expect(edited.status).toBe('saved')
|
||||
expect(f.updateMenu).toHaveBeenCalledOnce()
|
||||
f.contents.send.mockClear()
|
||||
const click = menu[0]!.click as () => void
|
||||
click()
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
|
||||
{ kind: 'menu', commandId: 'page.close', revision: edited.snapshot.revision })
|
||||
await f.call(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code: 'KeyQ', modifiers: ['primary'] } }, edited.snapshot.revision)
|
||||
expect(f.updateMenu).toHaveBeenCalledTimes(2)
|
||||
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
expect(f.updateMenu).toHaveBeenCalledTimes(3)
|
||||
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
expect(f.updateMenu).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('prioritizes %s custom editing bindings before both main and embedded frame handlers', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'session.new', defaults: desktopDefaults({ code: 'KeyN', modifiers: ['primary'] }) }])
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'session.new', binding: { code: 'KeyC', modifiers: ['primary'] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
const input = { modifiers: [], type: 'keyDown', code: 'KeyC', key: 'c', meta: platform === 'macos',
|
||||
control: platform === 'windows', alt: false, shift: false, isAutoRepeat: false, isComposing: false }
|
||||
const preventDefault = vi.fn()
|
||||
const press = (changes = {}) => f.contents.emit('before-input-event', { preventDefault }, { ...input, ...changes })
|
||||
f.contents.send.mockClear()
|
||||
press()
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'keyboard', code: 'KeyC' }))
|
||||
press({ type: 'keyUp' })
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2)
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } })
|
||||
press()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', frameName: 'embedded' }))
|
||||
await f.call(DESKTOP_IPC.shortcutsRecording, true)
|
||||
preventDefault.mockClear(); f.contents.send.mockClear()
|
||||
press()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
await f.call(DESKTOP_IPC.shortcutsRecording, false)
|
||||
press({ isComposing: true })
|
||||
press({ modifiers: ['altgr'] })
|
||||
press({ key: 'Dead' })
|
||||
press()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'session.new', binding: null }, saved.snapshot.revision)
|
||||
f.contents.send.mockClear()
|
||||
press()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('intercepts complete %s chords once and leaves their first key untouched', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }])
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
|
||||
expect(menu[0]?.accelerator).toBeUndefined()
|
||||
const preventDefault = vi.fn()
|
||||
const press = (code: string, type = 'keyDown', isAutoRepeat = false) => {
|
||||
f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3), type, modifiers: [],
|
||||
control: false, alt: false, shift: false, meta: false, isAutoRepeat, isComposing: false })
|
||||
}
|
||||
for (const embedded of [false, true]) {
|
||||
Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame })
|
||||
f.contents.send.mockClear(); preventDefault.mockClear()
|
||||
press('KeyA'); press('KeyA', 'keyUp'); press('KeyB'); press('KeyB', 'keyUp')
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
press('KeyB')
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
press('KeyA')
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyA', secondCode: 'KeyB', repeat: false }))
|
||||
press('KeyA', 'keyUp'); press('KeyB', 'keyUp')
|
||||
}
|
||||
for (const reset of [() => { f.window.emit('blur') },
|
||||
() => { press('MetaLeft', 'keyUp') },
|
||||
() => { Object.assign(f.contents, { focusedFrame: f.frame }) }]) {
|
||||
press('KeyA'); reset(); f.contents.send.mockClear(); press('KeyB')
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
press('KeyA', 'keyUp'); press('KeyB', 'keyUp')
|
||||
}
|
||||
f.contents.send.mockClear()
|
||||
press('KeyC'); press('KeyA'); press('KeyB')
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['C', 'V', 'Z'])('delivers Windows Edit %s to the editor and physical input to its shortcut', async (keyCode) => {
|
||||
const f = await fixture('windows')
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: `Key${keyCode}`, modifiers: ['control'] } }, initial.revision)
|
||||
const registry = new ShortcutRegistry('desktop', 'windows', saved.snapshot)
|
||||
const run = vi.fn()
|
||||
registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page', 'editable'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run }) })
|
||||
let nativeInput: (input: DesktopShortcutInput) => void = () => {}
|
||||
onTestFinished(installNativeKeyboard(window, {
|
||||
closeWindow: vi.fn(), subscribe: (listener) => { nativeInput = listener; return () => {} },
|
||||
}, registry, () => registry.config.getSnapshot()))
|
||||
onTestFinished(installKeyboard(window, registry, undefined, true))
|
||||
f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => {
|
||||
if (channel === DESKTOP_IPC.shortcutsInput) nativeInput(input)
|
||||
})
|
||||
const editorInput: string[] = []
|
||||
const deliver = (type: 'keyDown' | 'keyUp'): void => {
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type, code: `Key${keyCode}`, key: keyCode.toLowerCase(),
|
||||
modifiers: ['control'], control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
if (!preventDefault.mock.calls.length) {
|
||||
editorInput.push(type)
|
||||
document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup',
|
||||
{ code: `Key${keyCode}`, key: keyCode.toLowerCase(), ctrlKey: true, bubbles: true, cancelable: true }))
|
||||
}
|
||||
}
|
||||
f.contents.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { deliver(input.type) })
|
||||
f.contents.send.mockClear()
|
||||
|
||||
f.keyboard.sendEditingKey(keyCode, ['control'])
|
||||
|
||||
expect(f.contents.focus).toHaveBeenCalledOnce()
|
||||
expect(editorInput).toEqual(['keyDown', 'keyUp'])
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(f.contents.sendInputEvent.mock.calls).toEqual([
|
||||
[{ type: 'keyDown', keyCode, modifiers: ['control'] }],
|
||||
[{ type: 'keyUp', keyCode, modifiers: ['control'] }],
|
||||
])
|
||||
editorInput.length = 0
|
||||
deliver('keyDown')
|
||||
deliver('keyUp')
|
||||
expect(editorInput).toEqual([])
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'keyboard', code: `Key${keyCode}` }))
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores shortcut interception after native Edit delivery fails', async () => {
|
||||
const f = await fixture('windows')
|
||||
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
f.contents.sendInputEvent.mockImplementationOnce(() => { throw new Error('editor unavailable') })
|
||||
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).toThrow('editor unavailable')
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: ['control'],
|
||||
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
f.window.isDestroyed.mockReturnValue(true)
|
||||
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow()
|
||||
f.detach()
|
||||
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow()
|
||||
expect(f.contents.sendInputEvent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('intercepts %s standalone custom keys and clears held state when preferences change', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) {
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code, modifiers: [] } }, snapshot.revision)
|
||||
expect(saved.status).toBe('saved'); snapshot = saved.snapshot
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code, key: code, modifiers: [],
|
||||
control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, isComposing: false })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ code, revision: snapshot.revision }))
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('keeps %s native interception identical for inherited, saved and reset bindings', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
for (const operation of [null,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyB', modifiers: ['primary'] } },
|
||||
{ type: 'reset', id: 'sidebar.left.toggle' }]) {
|
||||
if (operation !== null) {
|
||||
const result = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, operation, snapshot.revision)
|
||||
expect(result.status).toBe('saved'); snapshot = result.snapshot
|
||||
}
|
||||
for (const embedded of [false, true]) {
|
||||
Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame })
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [],
|
||||
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyB', revision: snapshot.revision }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('consumes a held single-key repeat after focus reset without treating it as a fresh press', async () => {
|
||||
const f = await fixture('macos')
|
||||
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const preventDefault = vi.fn()
|
||||
const input = { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [],
|
||||
control: false, meta: true, alt: false, shift: false, isAutoRepeat: false, isComposing: false }
|
||||
f.contents.emit('before-input-event', { preventDefault }, input)
|
||||
f.window.emit('blur')
|
||||
f.contents.send.mockClear(); preventDefault.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { ...input, isAutoRepeat: true })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ code: 'KeyB', repeat: true }))
|
||||
})
|
||||
|
||||
it('delivers a fresh unbound key release after macOS omitted the previous Command character release', async () => {
|
||||
const f = await fixture('macos')
|
||||
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const preventDefault = vi.fn()
|
||||
const press = (code: string, type: string, meta: boolean): void => {
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type, code, key: code, modifiers: [],
|
||||
control: false, meta, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
}
|
||||
press('KeyB', 'keyDown', true)
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
press('MetaLeft', 'keyUp', false)
|
||||
preventDefault.mockClear()
|
||||
press('KeyB', 'keyDown', false); press('KeyB', 'keyUp', false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('releases %s native input when a saved binding overlaps a mounted fixed action', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'Escape', modifiers: [] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
await f.call(DESKTOP_IPC.shortcutsGet, [...f.definitions,
|
||||
{ id: 'menu.dismiss', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }])
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.send.mockClear()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'Escape', key: 'Escape', modifiers: [],
|
||||
control: false, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('keeps %s chord presses singular across native interception and renderer key delivery', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision)
|
||||
const registry = new ShortcutRegistry('desktop', platform, saved.snapshot)
|
||||
const run = vi.fn()
|
||||
registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run }) })
|
||||
let deliver: (input: DesktopShortcutInput) => void = () => {}
|
||||
onTestFinished(installNativeKeyboard(window, { closeWindow: vi.fn(), subscribe: (listener) => { deliver = listener; return () => {} } },
|
||||
registry, () => registry.config.getSnapshot()))
|
||||
onTestFinished(installKeyboard(window, registry, undefined, true))
|
||||
f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => {
|
||||
if (channel === DESKTOP_IPC.shortcutsInput) deliver(input)
|
||||
})
|
||||
const rendererReleases: string[] = []
|
||||
const press = (code: string, type: 'keyDown' | 'keyUp', repeat = false): void => {
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [],
|
||||
control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false })
|
||||
// Electron only delivers input to the document when the main process did not intercept it.
|
||||
if (!preventDefault.mock.calls.length) {
|
||||
if (type === 'keyUp') rendererReleases.push(code)
|
||||
document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup',
|
||||
{ code, key: code.slice(3).toLowerCase(), repeat, bubbles: true, cancelable: true }))
|
||||
}
|
||||
}
|
||||
for (const [index, [first, second]] of ([['KeyF', 'KeyG'], ['KeyG', 'KeyF']] as const).entries()) {
|
||||
press(first, 'keyDown'); press(first, 'keyUp'); press(second, 'keyDown'); press(second, 'keyUp')
|
||||
expect(run).toHaveBeenCalledTimes(index)
|
||||
press(first, 'keyDown'); press(second, 'keyDown'); press(second, 'keyDown', true); press(first, 'keyDown', true)
|
||||
expect(run).toHaveBeenCalledTimes(index + 1)
|
||||
if (index === 0) { press(first, 'keyUp'); press(second, 'keyUp') }
|
||||
else { press(second, 'keyUp'); press(first, 'keyUp') }
|
||||
press(second, 'keyDown'); press(second, 'keyUp'); press(first, 'keyDown'); press(first, 'keyUp')
|
||||
expect(run).toHaveBeenCalledTimes(index + 1)
|
||||
}
|
||||
expect(rendererReleases.filter(code => code === 'KeyF')).toHaveLength(5)
|
||||
expect(rendererReleases.filter(code => code === 'KeyG')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('requires fresh %s chord presses when Electron omits intercepted key releases', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const bindings = [
|
||||
{ id: 'sidebar.left.toggle', binding: { code: 'KeyJ', secondCode: 'KeyK', modifiers: [] } },
|
||||
{ id: 'sidebar.right.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } },
|
||||
{ id: 'shortcuts.open', binding: { code: 'KeyV', modifiers: [] } },
|
||||
]
|
||||
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
bindings.map(({ id }) => ({ id, defaults: {} })))
|
||||
for (const binding of bindings) {
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'set', ...binding }, snapshot.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
snapshot = saved.snapshot
|
||||
}
|
||||
const press = (code: string, type = 'keyDown', repeat = false): boolean => {
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [],
|
||||
control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false })
|
||||
return preventDefault.mock.calls.length > 0
|
||||
}
|
||||
for (const embedded of [false, true]) {
|
||||
Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame })
|
||||
f.contents.send.mockClear()
|
||||
expect(press('KeyJ')).toBe(false)
|
||||
expect(press('KeyK')).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
// Electron on macOS can omit both releases after intercepting the second keydown.
|
||||
for (const code of ['KeyJ', 'KeyK']) {
|
||||
expect(press(code)).toBe(false)
|
||||
expect(press(code, 'keyUp')).toBe(false)
|
||||
}
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
expect(press('KeyK')).toBe(false)
|
||||
expect(press('KeyJ')).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(2)
|
||||
expect(press('KeyJ', 'keyDown', true)).toBe(true)
|
||||
expect(press('KeyK', 'keyDown', true)).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(2)
|
||||
expect(press('KeyF')).toBe(false)
|
||||
expect(press('KeyG')).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(3)
|
||||
expect(press('KeyV')).toBe(true)
|
||||
expect(press('KeyJ')).toBe(false)
|
||||
expect(press('KeyK')).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(5)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser guest input to its owner and stops delivery before guest destruction', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'browser.new', defaults: desktopDefaults({ code: 'KeyT', modifiers: ['primary'] }) }])
|
||||
const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture
|
||||
const attach = vi.fn((guest: ContentsFixture, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window, guest, name))
|
||||
guests.bind(f.window, attach)
|
||||
const reservation = guests.acquire(f.contents, 'session:test')
|
||||
const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null }
|
||||
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true),
|
||||
setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(),
|
||||
focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
onTestFinished(() => { guest.emit('destroyed') })
|
||||
const rejected = { preventDefault: vi.fn() }
|
||||
f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition })
|
||||
expect(rejected.preventDefault).toHaveBeenCalledOnce()
|
||||
const invalid = Object.assign(new EventEmitter(), { getURL: () => 'about:blank#unknown', setWindowOpenHandler: vi.fn(), close: vi.fn() })
|
||||
f.contents.emit('did-attach-webview', {}, invalid)
|
||||
invalid.emit('dom-ready')
|
||||
expect(invalid.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false })
|
||||
expect(attach).not.toHaveBeenCalled()
|
||||
|
||||
const approved = { preventDefault: vi.fn() }
|
||||
f.contents.emit('will-attach-webview', approved, {}, { src: frame.url, partition: reservation.partition })
|
||||
expect(approved.preventDefault).not.toHaveBeenCalled()
|
||||
f.contents.emit('did-attach-webview', {}, guest)
|
||||
guest.emit('dom-ready')
|
||||
expect(attach).toHaveBeenCalledExactlyOnceWith(guest, reservation.lease)
|
||||
const input = { modifiers: [], type: 'keyDown', code: 'KeyT', key: 't', meta: platform === 'macos',
|
||||
control: platform !== 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false }
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.send.mockClear()
|
||||
guest.emit('before-input-event', { preventDefault }, input)
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, {
|
||||
kind: 'webview', frameName: reservation.lease, revision: snapshot.revision, code: 'KeyT',
|
||||
meta: platform === 'macos', control: platform !== 'macos', alt: false, shift: false, repeat: false,
|
||||
})
|
||||
guest.isFocused.mockReturnValue(false)
|
||||
guest.emit('before-input-event', { preventDefault }, input)
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
guest.isFocused.mockReturnValue(true)
|
||||
const release = guests.release(f.contents, reservation.lease)
|
||||
expect(guest.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false })
|
||||
expect(guest.listenerCount('before-input-event')).toBe(0)
|
||||
guest.emit('before-input-event', { preventDefault }, input)
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
guest.emit('destroyed')
|
||||
await release
|
||||
})
|
||||
|
||||
it('keeps browser guest chord state local and leaves accepted bindings active through guest navigation', async () => {
|
||||
const f = await fixture('macos')
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle',
|
||||
binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision)
|
||||
const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null }
|
||||
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(),
|
||||
send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
const blurListeners = f.window.listenerCount('blur')
|
||||
const closedListeners = f.window.listenerCount('closed')
|
||||
const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId)
|
||||
onTestFinished(dispose)
|
||||
expect(f.window.listenerCount('blur')).toBe(blurListeners)
|
||||
expect(f.window.listenerCount('closed')).toBe(closedListeners)
|
||||
const press = (contents: EventEmitter, code: string): void => {
|
||||
contents.emit('before-input-event', { preventDefault: vi.fn() }, { modifiers: [], type: 'keyDown', code, key: code,
|
||||
meta: false, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
}
|
||||
f.contents.send.mockClear()
|
||||
press(guest, 'KeyF')
|
||||
guest.emit('blur')
|
||||
press(guest, 'KeyG')
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
f.window.emit('blur')
|
||||
press(guest, 'KeyF')
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
press(guest, 'KeyF')
|
||||
press(f.contents, 'KeyG')
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
press(guest, 'KeyG')
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'webview', frameName: 'guest', code: 'KeyF', secondCode: 'KeyG' }))
|
||||
guest.emit('destroyed')
|
||||
press(f.contents, 'KeyF')
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(2)
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'keyboard', code: 'KeyF', secondCode: 'KeyG' }))
|
||||
})
|
||||
|
||||
it('delivers Windows Edit actions to the focused browser guest without invoking its custom shortcut', async () => {
|
||||
const f = await fixture('windows')
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle',
|
||||
binding: { code: 'KeyC', modifiers: ['control'] } }, initial.revision)
|
||||
const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null }
|
||||
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(),
|
||||
send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId)
|
||||
onTestFinished(dispose)
|
||||
const events: string[] = []
|
||||
guest.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => {
|
||||
const preventDefault = vi.fn()
|
||||
guest.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyC', key: 'c', modifiers: ['control'],
|
||||
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
if (!preventDefault.mock.calls.length) events.push(input.type)
|
||||
})
|
||||
f.contents.send.mockClear()
|
||||
f.keyboard.sendEditingKey('C', ['control'])
|
||||
expect(guest.focus).toHaveBeenCalledOnce()
|
||||
expect(events).toEqual(['keyDown', 'keyUp'])
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
expect(f.contents.sendInputEvent).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
f.keyboard.sendEditingKey('C', ['control'])
|
||||
expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
@@ -61,8 +61,6 @@ const harness = await vi.hoisted(async () => {
|
||||
getURL: () => this.urls.at(-1) ?? '',
|
||||
mainFrame: { url: '' },
|
||||
getZoomFactor: () => 1,
|
||||
isDestroyed: () => this.destroyed,
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
sendInputEvent: vi.fn(),
|
||||
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
|
||||
@@ -133,7 +131,6 @@ const harness = await vi.hoisted(async () => {
|
||||
getPreferredSystemLanguages: () => ['en-US'],
|
||||
getVersion: () => '1.0.0',
|
||||
getAppPath: (): string => 'desktop-test-app',
|
||||
getPath: vi.fn<(name: 'userData') => string>(),
|
||||
setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(),
|
||||
requestSingleInstanceLock: () => true,
|
||||
setAsDefaultProtocolClient: vi.fn(),
|
||||
@@ -317,9 +314,6 @@ beforeEach(() => {
|
||||
testAuth.login.mockResolvedValue('cancelled')
|
||||
vi.useFakeTimers()
|
||||
harness.reset()
|
||||
const userData = mkdtempSync(join(tmpdir(), 'dsh-main-user-data-'))
|
||||
onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) })
|
||||
harness.app.getPath.mockReturnValue(userData)
|
||||
harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => {
|
||||
if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 })
|
||||
harness.dialogShown.resolve()
|
||||
@@ -760,7 +754,7 @@ describe('desktop main startup', () => {
|
||||
await edit
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)('adds the product File menu and standard window commands only on macOS (%s)', async (platform) => {
|
||||
it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
@@ -771,7 +765,7 @@ describe('desktop main startup', () => {
|
||||
.find(items => items.some(item => item.role === 'editMenu'))
|
||||
if (template === undefined) throw new Error('application menu missing')
|
||||
expect(template.map(describeItem)).toEqual(platform === 'darwin'
|
||||
? ['Desktop test', en.fileMenu, 'editMenu', 'windowMenu']
|
||||
? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu']
|
||||
: ['Application', 'editMenu'])
|
||||
const application = template[0]!.submenu as MenuItemConstructorOptions[]
|
||||
expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
|
||||
import { syncWindowsAppearance } from '../src/preload-windows.ts'
|
||||
import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts'
|
||||
@@ -16,8 +15,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
|
||||
vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
|
||||
vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() }))
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('process', { ...process, isMainFrame: true }) })
|
||||
afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() })
|
||||
|
||||
it('limits product documents to update status and a native confirmation action', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/index.html'))
|
||||
@@ -123,7 +121,7 @@ it('moves welcome-entry focus to the document without changing keyboard tab orde
|
||||
})
|
||||
|
||||
it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => {
|
||||
vi.stubGlobal('process', { ...process, platform })
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) {
|
||||
vi.resetModules()
|
||||
vi.mocked(installMandatoryUpdateOverlay).mockClear()
|
||||
@@ -132,92 +130,3 @@ it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only i
|
||||
expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes constrained shortcut operations and releases configuration subscriptions', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/'))
|
||||
await import('../src/preload-app.ts')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
|
||||
await api.shortcuts.get([])
|
||||
expect(api.shortcuts).not.toHaveProperty('reload')
|
||||
await api.shortcuts.recording(true)
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsGet, [])
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsRecording, true)
|
||||
const listener = vi.fn()
|
||||
const off = api.shortcuts.subscribe(listener)
|
||||
const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsChanged)![1] as
|
||||
(event: unknown, state: unknown) => void
|
||||
handler({}, { status: 'ready' })
|
||||
expect(listener).toHaveBeenCalledWith({ status: 'ready' })
|
||||
off(); expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsChanged, handler)
|
||||
})
|
||||
|
||||
it('withholds the product API from same-origin child frames', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/'))
|
||||
vi.stubGlobal('process', { ...process, isMainFrame: false })
|
||||
await import('../src/preload-app.ts')
|
||||
expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
|
||||
})
|
||||
|
||||
it('forwards only the focused product iframe and releases native input subscriptions', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/'))
|
||||
await import('../src/preload-app.ts')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
|
||||
const listener = vi.fn()
|
||||
const off = api.keyboard.subscribe(listener)
|
||||
const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as
|
||||
(event: unknown, state: unknown) => void
|
||||
const input = { kind: 'iframe', frameName: 'preview', revision: 'current' }
|
||||
handler({}, input)
|
||||
const frame = document.createElement('iframe')
|
||||
document.body.append(frame)
|
||||
frame.name = 'preview'
|
||||
frame.focus()
|
||||
handler({}, input)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
frame.setAttribute('data-html-preview', '')
|
||||
handler({}, { ...input, frameName: 'stale' })
|
||||
handler({}, { ...input, frameName: '' })
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
handler({}, input)
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith(input)
|
||||
frame.remove()
|
||||
handler({}, input)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
const menu = { kind: 'menu', commandId: 'page.close', revision: 'current' }
|
||||
handler({}, menu)
|
||||
expect(listener).toHaveBeenLastCalledWith(menu)
|
||||
off()
|
||||
expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsInput, handler)
|
||||
})
|
||||
|
||||
it('forwards browser guest input only for the focused live webview lease', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/'))
|
||||
await import('../src/preload-app.ts')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
|
||||
const listener = vi.fn()
|
||||
const off = api.keyboard.subscribe(listener)
|
||||
const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as
|
||||
(event: unknown, state: unknown) => void
|
||||
const input = { kind: 'webview', frameName: 'guest', revision: 'current' }
|
||||
const frame = document.createElement('webview')
|
||||
frame.tabIndex = 0
|
||||
frame.setAttribute('name', 'guest')
|
||||
document.body.append(frame)
|
||||
frame.focus()
|
||||
handler({}, input)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
frame.setAttribute('data-sidebar-browser-frame', 'webview')
|
||||
handler({}, { ...input, frameName: '' })
|
||||
handler({}, { ...input, frameName: 'old' })
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
handler({}, input)
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith(input)
|
||||
const other = document.createElement('input')
|
||||
document.body.append(other)
|
||||
other.focus()
|
||||
handler({}, input)
|
||||
frame.remove()
|
||||
handler({}, input)
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
off()
|
||||
})
|
||||
@@ -45,7 +45,6 @@ vi.mock('electron', () => ({
|
||||
getVersion: () => '1.0.0',
|
||||
setAboutPanelOptions: vi.fn(),
|
||||
getAppPath: () => '/development-app',
|
||||
getPath: vi.fn<(name: 'userData') => string>().mockReturnValue('/desktop-user-data'),
|
||||
getPreferredSystemLanguages: () => ['en-US'],
|
||||
on: (name: string, callback: (...args: unknown[]) => void) => { state.appListeners.set(name, callback) },
|
||||
quit: state.quit,
|
||||
|
||||
@@ -22,12 +22,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/shortcuts/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/atomic-write"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
@@ -21,7 +21,7 @@ describe('web e2e: preset roster guidance', () => {
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await page.getByRole('heading', { name: 'Agent 预设' }).waitFor()
|
||||
}, 120_000)
|
||||
@@ -61,7 +61,7 @@ describe('web e2e: preset roster guidance', () => {
|
||||
// so the flow needs a connected workspace to enter.
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭', exact: true }).last().click()
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await settings.getByRole('button', { name: '让 Agent 帮我创建预设模式', exact: true }).click()
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu,
|
||||
import { openSettings,
|
||||
connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
|
||||
} from './support.ts'
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
@@ -347,7 +347,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
await dialog.getByRole('button', { name: 'Set as new task default: Minimal mode' }).click()
|
||||
@@ -364,7 +364,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
|
||||
// The switch controls availability only: re-enabling restores the saved
|
||||
// default and aligns this same still-blank task with it.
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const reopened = page.getByRole('dialog', { name: 'Settings' })
|
||||
await reopened.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const reopenedToggle = reopened.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor()
|
||||
const outside = page.getByRole('button', { name: 'Account menu', exact: true })
|
||||
const outside = page.getByRole('button', { name: 'Settings', exact: true })
|
||||
await outside.focus()
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await outside.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
|
||||
@@ -30,7 +30,7 @@ const PROMPT = `Write a file named notes.txt in the workspace containing exactly
|
||||
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
|
||||
const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
|
||||
|
||||
describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] as const)('web e2e: approval takeover through %s', (method) => {
|
||||
describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -119,11 +119,7 @@ describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] a
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
|
||||
if (method === 'button') await panel.getByRole('button', { name: 'Allow once' }).click()
|
||||
else {
|
||||
await scroll.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
await panel.getByRole('button', { name: 'Allow once' }).click()
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -136,7 +136,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
expect(await header.getByRole('tablist').count()).toBe(0)
|
||||
const compactHeight = await header.evaluate(element => element.getBoundingClientRect().height)
|
||||
expect(compactHeight).toBeLessThan(60)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('switch', { name: '开发者工具' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: '开发者工具' }).getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url))
|
||||
@@ -453,7 +453,7 @@ describe('web e2e: clickable links gallery', () => {
|
||||
await markdown.locator(`a[href="${HTTP_URL}"]`).click()
|
||||
await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('button', { name: 'Built-in browser', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'new-tab' })
|
||||
@@ -470,7 +470,7 @@ describe('web e2e: clickable links gallery', () => {
|
||||
}
|
||||
|
||||
await page.reload()
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('button', { name: 'New browser tab', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'sidebar' })
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
seedSession, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
|
||||
@@ -19,7 +19,7 @@ const SESSION_TITLE = 'Session action extension'
|
||||
|
||||
async function openInventory(page: Page, url: string) {
|
||||
await page.goto(url, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '内置插件', exact: true }).click()
|
||||
await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/** The shipped composer preserves drafts when an application Enter chord has extra modifiers. */
|
||||
import { chromium, type Page } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
|
||||
it('preserves the composer draft and menu for application Enter combinations', async () => {
|
||||
const scaffold = await launchWebScaffold({})
|
||||
const events: SessionEvent[] = []
|
||||
const off = scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
let failurePage: Page | undefined
|
||||
try {
|
||||
const page = await newEnglishPage(browser)
|
||||
failurePage = page
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-shortcuts')
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
for (const draft of ['unsent draft', '/']) {
|
||||
await writeComposerDraft(page, input, draft)
|
||||
const markup = await input.innerHTML()
|
||||
for (const chord of ['Alt+Enter', 'Meta+Alt+Enter', 'Control+Alt+Enter', 'Control+Meta+Enter', 'Meta+Shift+Enter', 'Control+Shift+Enter']) {
|
||||
await input.press(chord)
|
||||
expect(await input.innerHTML(), `${draft}: ${chord}`).toBe(markup)
|
||||
}
|
||||
await input.press('Escape')
|
||||
}
|
||||
await writeComposerDraft(page, input, 'first line')
|
||||
await input.press('Shift+Enter')
|
||||
await page.keyboard.type('second line')
|
||||
expect(await input.innerText()).toBe('first line\nsecond line')
|
||||
expect(events.filter(event => event.type === 'user/message')).toHaveLength(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-shortcuts')
|
||||
throw error
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
off()
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
|
||||
|
||||
@@ -39,13 +39,11 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
|
||||
expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-official', name: 'DeepSeek' })
|
||||
expect(scaffold.ctx.llm.listProviders().filter(provider => provider.id === 'deepseek-official')).toHaveLength(1)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
|
||||
await page.getByRole('dialog', { name: '开始你的创作' })
|
||||
.getByRole('button', { name: '添加 API Key', exact: true }).click()
|
||||
const onboarding = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await onboarding.getByLabel('API 密钥', { exact: true }).fill('sk-messages-onboarding')
|
||||
await onboarding.getByRole('button', { name: '保存并继续' }).click()
|
||||
await onboarding.waitFor({ state: 'detached' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await dialog.getByText('DeepSeek', { exact: true }).waitFor()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, saveFailureShot } from './support.ts'
|
||||
import { openSettings, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/desktop-locale', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -53,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
|
||||
it('uses OS languages without saving them, then shares an explicit choice across reloads', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-desktop-locale'))
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const zhDialog = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await zhDialog.getByRole('button', { name: '中文', exact: true }).waitFor()
|
||||
await expect.poll(() => reported).toEqual(['zh'])
|
||||
@@ -73,7 +73,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
|
||||
reported.length = 0
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor()
|
||||
await expect.poll(() => reported).toEqual(['en'])
|
||||
expect(await page.evaluate(() => document.documentElement.lang)).toBe('en')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { launchWebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage } from './support.ts'
|
||||
import { openSettings, newEnglishPage } from './support.ts'
|
||||
|
||||
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
@@ -13,7 +13,7 @@ it('persists developer tools in the Host settings document and restores the acce
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const toggle = page.getByRole('switch', { name: 'Developer tools' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toMatch(/id: ui-settings(?:\r?\n|$)/)
|
||||
@@ -21,7 +21,7 @@ it('persists developer tools in the Host settings document and restores the acce
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain('enabled: false')
|
||||
await page.reload()
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
|
||||
|
||||
@@ -14,7 +14,7 @@ import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documen
|
||||
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
|
||||
@@ -425,7 +425,7 @@ else process.exit(1);
|
||||
`- Local script: ${await basicHtml.locator('#local-result').innerText()}`,
|
||||
`- Network requests: ${previewNetworkRequests}`,
|
||||
].join('\n'))
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const settings = page.getByRole('dialog', { name: 'Settings' })
|
||||
await settings.getByRole('switch', { name: 'Developer tools' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: 'Developer tools' }).getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
- button "详细"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
- switch "开发者工具" [checked]
|
||||
- text: 快捷键
|
||||
- paragraph: 查看当前可用的快捷键和输入操作
|
||||
- button "查看快捷键"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -24,9 +24,6 @@
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
- switch "Developer tools" [checked]
|
||||
- text: Keyboard shortcuts
|
||||
- paragraph: Browse available shortcuts and input actions
|
||||
- button "View shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -1,6 +0,0 @@
|
||||
- dialog "开始你的创作":
|
||||
- heading "开始你的创作" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: 登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。
|
||||
- button "添加 API Key"
|
||||
- button "登录"
|
||||
@@ -24,9 +24,6 @@
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
- switch "Developer tools"
|
||||
- text: Keyboard shortcuts
|
||||
- paragraph: Browse available shortcuts and input actions
|
||||
- button "View shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -24,9 +24,6 @@
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
- switch "Developer tools"
|
||||
- text: Keyboard shortcuts
|
||||
- paragraph: Browse available shortcuts and input actions
|
||||
- button "View shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -24,9 +24,6 @@
|
||||
- button "详细"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
- switch "开发者工具"
|
||||
- text: 快捷键
|
||||
- paragraph: 查看当前可用的快捷键和输入操作
|
||||
- button "查看快捷键"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -1,5 +0,0 @@
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session A B": A B
|
||||
@@ -1,5 +0,0 @@
|
||||
- group "New Session":
|
||||
- button "Restore default"
|
||||
- button "Remove"
|
||||
- button "Press a shortcut"
|
||||
- text: Already used by “Open reference menu”
|
||||
@@ -1,5 +0,0 @@
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session ⌘ C": ⌘ C
|
||||
@@ -1,12 +0,0 @@
|
||||
# Modal layers
|
||||
|
||||
{
|
||||
"settingsOnTop": {
|
||||
"focused": "Settings",
|
||||
"foreground": "Settings"
|
||||
},
|
||||
"referenceOnTop": {
|
||||
"focused": "Keyboard shortcuts",
|
||||
"foreground": "Keyboard shortcuts"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
- group "New Session":
|
||||
- button "Restore default"
|
||||
- button "Remove"
|
||||
- button "Press a shortcut"
|
||||
@@ -1,5 +0,0 @@
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session Ctrl + C": Ctrl + C
|
||||
@@ -1,18 +0,0 @@
|
||||
- tablist:
|
||||
- tab "Browser Close":
|
||||
- text: Browser
|
||||
- button "Close"
|
||||
- tab "Browser Close" [selected]:
|
||||
- text: Browser
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split"
|
||||
- button "Fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- button "Back" [disabled]
|
||||
- button "Forward" [disabled]
|
||||
- button "Reload" [disabled]
|
||||
- textbox "Enter an HTTP(S) address"
|
||||
- button "Open in system browser" [disabled]
|
||||
- button "Disable sandbox restrictions"
|
||||
- text: Enter an HTTP(S) address to start browsing
|
||||
@@ -1,9 +0,0 @@
|
||||
- tablist:
|
||||
- tab "Start" [selected]
|
||||
- button "Split"
|
||||
- button "Exit fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- button "Workspace files Browse files in this session's workspace"
|
||||
- button "New terminal Run commands in the Session workspace"
|
||||
- button "Choose shell"
|
||||
- button "Browser Browse web pages"
|
||||
@@ -1,23 +0,0 @@
|
||||
- tablist:
|
||||
- tab "Files Close":
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- tab "bash Close" [selected]:
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- button "Fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- textbox "Terminal"
|
||||
- tablist:
|
||||
- tab "Files Close" [selected]:
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "t6-refresh.txt"
|
||||
@@ -1,25 +0,0 @@
|
||||
- tablist:
|
||||
- tab "Files Close":
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- tab "bash Close":
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- tab "bash Close" [selected]:
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- button "Exit fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- textbox "Terminal"
|
||||
- tablist:
|
||||
- tab "Files Close" [selected]:
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- list:
|
||||
- listitem: Empty directory
|
||||
@@ -1,2 +0,0 @@
|
||||
- menu:
|
||||
- menuitem "Close"
|
||||
@@ -1 +0,0 @@
|
||||
- alert: This session has no completed turn
|
||||
@@ -1,9 +0,0 @@
|
||||
- text: Workspaces
|
||||
- button "Search sessions"
|
||||
- textbox "Search session names"
|
||||
- button "View options"
|
||||
- button "Add workspace"
|
||||
- tree "Sessions":
|
||||
- treeitem "Ungrouped" [expanded]
|
||||
- treeitem "T4 source (1) now"
|
||||
- treeitem "T4 source 1min" [selected]
|
||||
@@ -1,132 +0,0 @@
|
||||
- dialog "Keyboard shortcuts":
|
||||
- banner:
|
||||
- heading "Keyboard shortcuts" [level=2]
|
||||
- button "Close keyboard shortcuts"
|
||||
- search "Search shortcuts":
|
||||
- searchbox "Search shortcuts"
|
||||
- region "Application":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Open keyboard shortcuts
|
||||
- button "Remove shortcut for Open keyboard shortcuts"
|
||||
- button "Edit shortcut for Open keyboard shortcuts"
|
||||
- button "Open keyboard shortcuts ⌘ /": ⌘ /
|
||||
- listitem:
|
||||
- text: Toggle left sidebar
|
||||
- button "Remove shortcut for Toggle left sidebar"
|
||||
- button "Edit shortcut for Toggle left sidebar"
|
||||
- button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B
|
||||
- listitem:
|
||||
- text: Toggle right sidebar
|
||||
- button "Remove shortcut for Toggle right sidebar"
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split ⌘ \\": ⌘ \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page No shortcut": No shortcut
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session ⌥ ⌘ N": ⌥ ⌘ N
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
|
||||
- listitem:
|
||||
- text: Add workspace
|
||||
- button "Remove shortcut for Add workspace"
|
||||
- button "Edit shortcut for Add workspace"
|
||||
- button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal ⌃ `"': "⌃ `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: New line
|
||||
- button "New line ⇧ Enter" [disabled]: ⇧ Enter
|
||||
- listitem:
|
||||
- text: Use the complementary Queue / Steer action
|
||||
- button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
|
||||
- listitem:
|
||||
- text: Open command menu
|
||||
- button "Open command menu /" [disabled]: /
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Move menu selection
|
||||
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: Select menu item
|
||||
- button "Select menu item Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Allow once
|
||||
- button "Allow once Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
- contentinfo:
|
||||
- button "Restore all defaults" [disabled]
|
||||
- text: 0 customized
|
||||
@@ -1,5 +0,0 @@
|
||||
- menu:
|
||||
- menuitem "Pin session"
|
||||
- menuitem "Rename"
|
||||
- menuitem "Fork session"
|
||||
- menuitem "Archive session"
|
||||
@@ -1,5 +0,0 @@
|
||||
- group "Toggle left sidebar":
|
||||
- button "Restore default" [disabled]
|
||||
- button "Press a shortcut"
|
||||
- text: Shortcut configuration or available commands changed. Review the latest bindings before saving. Shortcut configuration or available commands changed. Review the latest bindings before saving.
|
||||
- button "I have reviewed the latest configuration"
|
||||
@@ -1 +0,0 @@
|
||||
- alert: Already used by “Open keyboard shortcuts”
|
||||
@@ -1,10 +0,0 @@
|
||||
- dialog "Keyboard shortcuts":
|
||||
- banner:
|
||||
- heading "Keyboard shortcuts" [level=2]
|
||||
- button "Close keyboard shortcuts"
|
||||
- search "Search shortcuts":
|
||||
- searchbox "Search shortcuts": abc
|
||||
- status: No matching shortcuts
|
||||
- contentinfo:
|
||||
- button "Restore all defaults"
|
||||
- text: 1 customized
|
||||
@@ -1,5 +0,0 @@
|
||||
- group "Toggle left sidebar":
|
||||
- button "Restore default"
|
||||
- button "Remove"
|
||||
- button "Press a shortcut"
|
||||
- text: Use Ctrl+/, Ctrl+,, Ctrl+Alt+key, or Ctrl+Shift+key. Combinations with three or four distinct modifiers are also supported. Browser or system shortcuts may not reach the page.
|
||||
@@ -1,6 +0,0 @@
|
||||
- dialog "Restore all default shortcuts?":
|
||||
- heading "Restore all default shortcuts?" [level=2]
|
||||
- button "Close confirmation"
|
||||
- paragraph: Restore the default shortcuts for this platform. All modified or removed shortcuts will be restored. Other platforms are unaffected.
|
||||
- button "Cancel"
|
||||
- button "Restore default"
|
||||
@@ -1 +0,0 @@
|
||||
- alert: Modified
|
||||
@@ -1,133 +0,0 @@
|
||||
- dialog "Keyboard shortcuts":
|
||||
- banner:
|
||||
- heading "Keyboard shortcuts" [level=2]
|
||||
- button "Close keyboard shortcuts"
|
||||
- search "Search shortcuts":
|
||||
- searchbox "Search shortcuts"
|
||||
- region "Application":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Open keyboard shortcuts
|
||||
- button "Remove shortcut for Open keyboard shortcuts"
|
||||
- button "Edit shortcut for Open keyboard shortcuts"
|
||||
- button "Open keyboard shortcuts Ctrl + /": Ctrl + /
|
||||
- listitem:
|
||||
- text: Toggle left sidebar
|
||||
- button "Remove shortcut for Toggle left sidebar"
|
||||
- button "Edit shortcut for Toggle left sidebar"
|
||||
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
|
||||
- listitem:
|
||||
- text: Toggle right sidebar
|
||||
- button "Remove shortcut for Toggle right sidebar"
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split Ctrl + \\": Ctrl + \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Remove shortcut for Refresh current page"
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
|
||||
- listitem:
|
||||
- text: Add workspace
|
||||
- button "Remove shortcut for Add workspace"
|
||||
- button "Edit shortcut for Add workspace"
|
||||
- button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings Ctrl + ,": Ctrl + ,
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal Ctrl + `"': "Ctrl + `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: New line
|
||||
- button "New line Shift + Enter" [disabled]: Shift + Enter
|
||||
- listitem:
|
||||
- text: Use the complementary Queue / Steer action
|
||||
- button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
|
||||
- listitem:
|
||||
- text: Open command menu
|
||||
- button "Open command menu /" [disabled]: /
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Move menu selection
|
||||
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: Select menu item
|
||||
- button "Select menu item Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Allow once
|
||||
- button "Allow once Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
- contentinfo:
|
||||
- button "Restore all defaults" [disabled]
|
||||
- text: 0 customized
|
||||
@@ -1 +0,0 @@
|
||||
- alert: 已被「快捷键速查」占用
|
||||
@@ -1,10 +0,0 @@
|
||||
- dialog "快捷键":
|
||||
- banner:
|
||||
- heading "快捷键" [level=2]
|
||||
- button "关闭快捷键"
|
||||
- search "搜索快捷键":
|
||||
- searchbox "搜索快捷键": abc
|
||||
- status: 没有匹配的快捷键
|
||||
- contentinfo:
|
||||
- button "恢复全部默认"
|
||||
- text: 1 项已自定义
|
||||
@@ -1,5 +0,0 @@
|
||||
- group "展开/收起左侧栏":
|
||||
- button "恢复默认"
|
||||
- button "移除"
|
||||
- button "按下快捷键"
|
||||
- text: 可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。
|
||||
@@ -1,6 +0,0 @@
|
||||
- dialog "恢复全部默认快捷键?":
|
||||
- heading "恢复全部默认快捷键?" [level=2]
|
||||
- button "关闭确认"
|
||||
- paragraph: 将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。
|
||||
- button "取消"
|
||||
- button "恢复默认"
|
||||
@@ -1 +0,0 @@
|
||||
- alert: 已修改
|
||||
@@ -1,132 +0,0 @@
|
||||
- dialog "快捷键":
|
||||
- banner:
|
||||
- heading "快捷键" [level=2]
|
||||
- button "关闭快捷键"
|
||||
- search "搜索快捷键":
|
||||
- searchbox "搜索快捷键"
|
||||
- region "应用操作":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 快捷键速查
|
||||
- button "移除快捷键速查快捷键"
|
||||
- button "修改快捷键速查快捷键"
|
||||
- button "快捷键速查 ⌘ /": ⌘ /
|
||||
- listitem:
|
||||
- text: 展开/收起左侧栏
|
||||
- button "移除展开/收起左侧栏快捷键"
|
||||
- button "修改展开/收起左侧栏快捷键"
|
||||
- button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B
|
||||
- listitem:
|
||||
- text: 展开/收起右侧栏
|
||||
- button "移除展开/收起右侧栏快捷键"
|
||||
- button "修改展开/收起右侧栏快捷键"
|
||||
- button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B
|
||||
- listitem:
|
||||
- text: 分栏
|
||||
- button "移除分栏快捷键"
|
||||
- button "修改分栏快捷键"
|
||||
- button "分栏 ⌘ \\": ⌘ \
|
||||
- listitem:
|
||||
- text: 面板全屏/退出全屏
|
||||
- button "移除面板全屏/退出全屏快捷键"
|
||||
- button "修改面板全屏/退出全屏快捷键"
|
||||
- button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
|
||||
- listitem:
|
||||
- text: 关闭当前页面/窗口
|
||||
- button "移除关闭当前页面/窗口快捷键"
|
||||
- button "修改关闭当前页面/窗口快捷键"
|
||||
- button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
|
||||
- listitem:
|
||||
- text: 刷新当前页面
|
||||
- button "修改刷新当前页面快捷键"
|
||||
- button "刷新当前页面 暂无快捷键": 暂无快捷键
|
||||
- listitem:
|
||||
- text: 新会话
|
||||
- button "移除新会话快捷键"
|
||||
- button "修改新会话快捷键"
|
||||
- button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
|
||||
- listitem:
|
||||
- text: 搜索会话
|
||||
- button "移除搜索会话快捷键"
|
||||
- button "修改搜索会话快捷键"
|
||||
- button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
|
||||
- listitem:
|
||||
- text: 添加工作区
|
||||
- button "移除添加工作区快捷键"
|
||||
- button "修改添加工作区快捷键"
|
||||
- button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O
|
||||
- listitem:
|
||||
- text: 重命名会话
|
||||
- button "移除重命名会话快捷键"
|
||||
- button "修改重命名会话快捷键"
|
||||
- button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
|
||||
- listitem:
|
||||
- text: 分叉会话
|
||||
- button "移除分叉会话快捷键"
|
||||
- button "修改分叉会话快捷键"
|
||||
- button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
|
||||
- listitem:
|
||||
- text: 归档会话
|
||||
- button "移除归档会话快捷键"
|
||||
- button "修改归档会话快捷键"
|
||||
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: 打开设置
|
||||
- button "移除打开设置快捷键"
|
||||
- button "修改打开设置快捷键"
|
||||
- button "打开设置 ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: 工作区文件
|
||||
- button "移除工作区文件快捷键"
|
||||
- button "修改工作区文件快捷键"
|
||||
- button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
|
||||
- listitem:
|
||||
- text: 新建终端
|
||||
- button "移除新建终端快捷键"
|
||||
- button "修改新建终端快捷键"
|
||||
- 'button "新建终端 ⌃ `"': "⌃ `"
|
||||
- region "消息输入":
|
||||
- heading "消息输入" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 发送消息
|
||||
- button "发送消息 Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: 换行
|
||||
- button "换行 ⇧ Enter" [disabled]: ⇧ Enter
|
||||
- listitem:
|
||||
- text: 使用互补的 Queue/Steer 方式
|
||||
- button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
|
||||
- listitem:
|
||||
- text: 打开命令菜单
|
||||
- button "打开命令菜单 /" [disabled]: /
|
||||
- listitem:
|
||||
- text: 打开引用菜单
|
||||
- button "打开引用菜单 @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: 停止生成
|
||||
- button "停止生成 Esc Esc" [disabled]: Esc Esc
|
||||
- region "菜单与弹层":
|
||||
- heading "菜单与弹层" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 移动菜单选择
|
||||
- button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: 选择菜单项
|
||||
- button "选择菜单项 Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: 关闭菜单或顶层弹窗
|
||||
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
|
||||
- region "审批区域":
|
||||
- heading "审批区域" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 允许一次
|
||||
- button "允许一次 Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: 拒绝
|
||||
- button "拒绝 Esc" [disabled]: Esc
|
||||
- contentinfo:
|
||||
- button "恢复全部默认" [disabled]
|
||||
- text: 0 项已自定义
|
||||
@@ -0,0 +1,4 @@
|
||||
- alert:
|
||||
- paragraph: "Cannot download model.int8.onnx: the download service returned HTTP 503."
|
||||
- paragraph: Confirm that the download address is available and the proxy can reach the download service, or retry later.
|
||||
- text: "Download source: https://model-mirror.example"
|
||||
@@ -187,7 +187,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
|
||||
const stopButton = page.getByRole('button', { name: 'Stop generating' })
|
||||
await stopButton.hover()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
|
||||
await stopButton.click()
|
||||
await settled
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
@@ -321,7 +321,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
it.skipIf(MODE === 'record')('persists performance detail and hides statistics in Compact', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-performance-usage'))
|
||||
const stats = page.locator('[data-composer-stats]')
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const row = dialog.getByText('Performance & usage', { exact: true }).locator('../..')
|
||||
await row.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
@@ -336,7 +336,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-stats]', scaffold.workspaceCwd), MODE)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload()
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await row.getByRole('button', { name: 'Compact', exact: true }).waitFor()
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await row.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url))
|
||||
const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; '
|
||||
@@ -37,7 +37,7 @@ describe('web e2e: repairs a stored provider after catalog drift', () => {
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await dialog.getByText(FAILURE, { exact: true }).waitFor()
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
@@ -72,7 +72,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
it('opens the add card over the dormant directory vocabulary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
|
||||
@@ -16,11 +16,10 @@ import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-deepseek-config', import.meta.url))
|
||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
||||
const SIGN_IN_EXPECTED = join(SNAPSHOT_DIR, 'sign-in.expected.md')
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
|
||||
const DEFAULT_MODELS_EXPECTED = join(SNAPSHOT_DIR, 'default-models.expected.md')
|
||||
@@ -73,13 +72,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const signIn = page.getByRole('dialog', { name: '开始你的创作' })
|
||||
await signIn.waitFor({ timeout: 15_000 })
|
||||
await compareOrRefreshGolden(SIGN_IN_EXPECTED,
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
|
||||
await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click()
|
||||
const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0)
|
||||
const keyInput = credentialStep.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
@@ -102,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
|
||||
// The ordinary Models surface reuses the refreshed join and exposes the
|
||||
// configured write-only placeholder without a reload.
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
@@ -203,7 +199,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
|
||||
// Opened here rather than inherited: the credential test reloads the page
|
||||
// after configuring the key, so nothing carries an open dialog across.
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
@@ -292,7 +288,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['welcome.expected.md', 'sign-in.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
|
||||
['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-native', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -52,7 +52,14 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
}
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
const accountMenu = page.getByRole('button', { name: '账号菜单', exact: true })
|
||||
if (desktop) await accountMenu.waitFor()
|
||||
else {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).waitFor()
|
||||
expect(await accountMenu.count()).toBe(0)
|
||||
expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0)
|
||||
}
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await settings.getByLabel('API 密钥', { exact: true }).waitFor()
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-usable-provider', import.meta.url))
|
||||
const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
|
||||
@@ -44,15 +44,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
|
||||
it('closes the setup card without discarding the add card beside it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
|
||||
const signIn = page.getByRole('dialog', { name: '开始你的创作' })
|
||||
await signIn.waitFor({ timeout: 15_000 })
|
||||
await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click()
|
||||
const credentialStep = page.getByRole('dialog', { name: CREDENTIAL_STEP })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0)
|
||||
await credentialStep.getByRole('button', { name: '稍后配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
// Dismissing the onboarding step leaves Settings closed, so enter the
|
||||
@@ -116,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
|
||||
// The Models page agrees: DeepSeek stays a row rather than reopening its
|
||||
// setup card over a user who already has somewhere to send a request.
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettingsFromAccountMenu, saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettings, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
|
||||
const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
|
||||
@@ -64,7 +64,7 @@ describe('web e2e: plugin manager', () => {
|
||||
const source = language === 'en' ? '中文' : 'English'
|
||||
const target = language === 'en' ? 'English' : '中文'
|
||||
if (await page.getByRole('dialog', { name: settings }).count() === 0) {
|
||||
await openSettingsFromAccountMenu(page, language === 'en' ? 'zh' : 'en')
|
||||
await openSettings(page, language === 'en' ? 'zh' : 'en')
|
||||
}
|
||||
await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click()
|
||||
await page.getByRole('menuitem', { name: target }).click()
|
||||
|
||||
@@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
const continueButton = page.getByRole('button', { name: 'Continue' })
|
||||
await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS })
|
||||
await continueButton.click()
|
||||
await page.getByRole('button', { name: 'Add API Key', exact: true }).click()
|
||||
const configureLater = page.getByRole('button', { name: 'Configure later' })
|
||||
await configureLater.waitFor({ timeout: 30_000 })
|
||||
await configureLater.click()
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('web e2e: queue row actions', () => {
|
||||
await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each(['button', 'keyboard'] as const)('edits and removes exact occurrences and preserves Queue across %s stop', async (method) => {
|
||||
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
@@ -281,13 +281,8 @@ describe('web e2e: queue row actions', () => {
|
||||
|
||||
const stopButton = page.getByRole('button', { name: 'Stop generating' })
|
||||
await stopButton.hover()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
|
||||
if (method === 'button') await stopButton.click()
|
||||
else {
|
||||
await input.focus()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
|
||||
await stopButton.click()
|
||||
await firstSettled
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
|
||||
.toBe(0)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { chromium, type Browser } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -23,17 +22,11 @@ describe('navigation without a selected Session', () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
if (platform !== 'web') {
|
||||
await page.addInitScript(({ value, snapshot }) => {
|
||||
Object.assign(window, { dshDesktop: { protocolVersion: 1,
|
||||
keyboard: { subscribe: () => () => {}, closeWindow: async () => {} },
|
||||
shortcuts: { get: async () => ({ ...snapshot, status: 'ready' }),
|
||||
subscribe: () => () => {}, recording: async () => {},
|
||||
edit: async () => ({ status: 'not-ready', snapshot }) },
|
||||
} })
|
||||
await page.addInitScript((value) => {
|
||||
const mark = () => { document.documentElement.setAttribute('data-platform', value) }
|
||||
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
|
||||
else mark()
|
||||
}, { value: platform, snapshot: initialShortcutConfig() })
|
||||
}, platform)
|
||||
}
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/settings-chrome', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
@@ -59,15 +59,13 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('opens the settings dialog, switches sections, and closes by every path', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
|
||||
const trigger = page.getByRole('button', { name: '账号菜单', exact: true })
|
||||
expect(await trigger.getAttribute('aria-haspopup')).toBe('menu')
|
||||
const trigger = page.getByRole('button', { name: '设置', exact: true })
|
||||
expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
await trigger.click()
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
await page.getByRole('menuitem', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
|
||||
@@ -186,7 +184,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
// Close path 2: the header close button (focus lands there on open).
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -198,7 +196,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation'))
|
||||
const console = watchConsole(overflowPage)
|
||||
await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(overflowPage, 'zh')
|
||||
await openSettings(overflowPage, 'zh')
|
||||
const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true })
|
||||
const navigation = dialog.getByRole('navigation')
|
||||
const list = navigation.locator('[class*="_navList"]')
|
||||
@@ -242,7 +240,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: '工作区内修改' })
|
||||
@@ -309,7 +307,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
it('uses the persisted dark preference while plugins are still loading', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme'))
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const initialDialog = page.getByRole('dialog', { name: '设置' })
|
||||
const darkCube = initialDialog.getByRole('button', { name: '深色' })
|
||||
await selectTheme(darkCube, 'dark')
|
||||
@@ -355,7 +353,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const restoredDialog = page.getByRole('dialog', { name: '设置' })
|
||||
const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
|
||||
// The boot palette precedes the settings mirror's saved preference.
|
||||
@@ -409,7 +407,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(light.faviconPaths).toEqual(['/favicon.svg'])
|
||||
expectThemeColorSynchronized(light)
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const darkCube = dialog.getByRole('button', { name: '深色' })
|
||||
@@ -463,7 +461,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
}
|
||||
|
||||
// `system` follows the emulated OS scheme (dark stays dark, light clears).
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
|
||||
await selectTheme(systemCube, 'system')
|
||||
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
@@ -525,7 +523,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
}
|
||||
expect(await readFontSize()).toBe('14px')
|
||||
expect(await readSecondaryFontSize()).toBe('13px')
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
// The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16.
|
||||
@@ -550,7 +548,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(await readSecondaryFontSize()).toBe('14px')
|
||||
|
||||
// Restore the default for the specs that follow (and the dialog golden).
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const restored = page.getByRole('dialog', { name: '设置' })
|
||||
await restored.waitFor({ timeout: 10_000 })
|
||||
await restored.getByText('16', { exact: true }).hover()
|
||||
@@ -565,7 +563,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
['detailed', '详细'], ['expanded', '完全展开'],
|
||||
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
@@ -581,7 +579,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..')
|
||||
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
|
||||
@@ -597,7 +595,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '排队发送' }).click()
|
||||
@@ -612,7 +610,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -623,7 +621,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSettingsFromAccountMenu(secondPage, 'zh')
|
||||
await openSettings(secondPage, 'zh')
|
||||
await secondPage.getByRole('dialog', { name: '设置' })
|
||||
.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
@@ -646,7 +644,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('persists the settings language across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const zhDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await zhDialog.waitFor({ timeout: 10_000 })
|
||||
// The document language follows the active locale in the assembled app, not
|
||||
@@ -678,7 +676,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
const enTrigger = page.getByRole('button', { name: 'Account menu' })
|
||||
const enTrigger = page.getByRole('button', { name: 'Settings' })
|
||||
await enTrigger.waitFor({ timeout: 10_000 })
|
||||
|
||||
// A Chinese browser on another port still receives the explicit English
|
||||
@@ -690,7 +688,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSettingsFromAccountMenu(secondPage, 'en')
|
||||
await openSettings(secondPage, 'en')
|
||||
await secondPage.getByRole('dialog', { name: 'Settings' })
|
||||
.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
@@ -701,7 +699,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await second.close()
|
||||
}
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
|
||||
await page.getByRole('menuitem', { name: '中文' }).click()
|
||||
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
|
||||
@@ -725,7 +723,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await enPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await openSettingsFromAccountMenu(enPage, 'en')
|
||||
await openSettings(enPage, 'en')
|
||||
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
@@ -764,7 +762,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await openSettingsFromAccountMenu(frPage, 'en')
|
||||
await openSettings(frPage, 'en')
|
||||
const dialog = frPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
@@ -798,7 +796,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
onTestFinished(() => withoutBrowser.close())
|
||||
const browserConsole = watchConsole(withoutBrowser)
|
||||
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(withoutBrowser, 'en')
|
||||
await openSettings(withoutBrowser, 'en')
|
||||
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
|
||||
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
/** Real application composition and Desktop persistence with a substituted Electron preload transport. */
|
||||
import { mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { desktopKeybindings } from '../../desktop/src/keybindings.ts'
|
||||
import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
import { writeComposerDraft } from './support.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
desktopShortcutsGet: DesktopShortcutsApi['get']
|
||||
desktopShortcutsEdit: DesktopShortcutsApi['edit']
|
||||
shortcutFixture: { deliver(input: DesktopShortcutInput): void; recording: boolean; closedWindows: number }
|
||||
}
|
||||
}
|
||||
|
||||
const expected = fileURLToPath(new URL('./expected/shortcuts-desktop', import.meta.url))
|
||||
const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
it.each([
|
||||
{ platform: 'macos', marker: 'darwin', primary: 'Meta' },
|
||||
{ platform: 'windows', marker: 'win32', primary: 'Control' },
|
||||
] as const)('records, persists and prioritizes $platform Desktop single keys and chords over recorded history', async ({ platform, marker, primary }) => {
|
||||
const userData = await mkdtemp(join(tmpdir(), 'dsh-shortcuts-desktop-'))
|
||||
let snapshot = initialShortcutConfig()
|
||||
const persistence = desktopKeybindings(userData, platform, (value) => { snapshot = value })
|
||||
try {
|
||||
const scaffold = await launchWebScaffold({ developerTools: false })
|
||||
try {
|
||||
await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('desktop-shortcuts-source'))
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await page.exposeFunction('desktopShortcutsGet', async (definitions: unknown) => {
|
||||
persistence.setDefinitions(parseShortcutDefinitions(definitions))
|
||||
return persistence.readCurrent()
|
||||
})
|
||||
await page.exposeFunction('desktopShortcutsEdit', (edit: unknown, revision: ShortcutRevision) => persistence.edit(parseShortcutEdit(edit), revision))
|
||||
// Only the Electron transport is substituted; preference storage and all Client plugins are real.
|
||||
await page.addInitScript((device) => {
|
||||
const mark = () => { document.documentElement.dataset.platform = device }
|
||||
if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark)
|
||||
else mark()
|
||||
const listeners = new Set<(input: DesktopShortcutInput) => void>()
|
||||
const fixture = { recording: false, closedWindows: 0,
|
||||
deliver(input: DesktopShortcutInput) { for (const listener of listeners) listener(input) },
|
||||
}
|
||||
Object.assign(window, { shortcutFixture: fixture, dshDesktop: { protocolVersion: 1,
|
||||
shortcuts: {
|
||||
get: window.desktopShortcutsGet, edit: window.desktopShortcutsEdit,
|
||||
recording: async (active: boolean) => { fixture.recording = active }, subscribe: () => () => {},
|
||||
},
|
||||
keyboard: { subscribe: (listener: (input: DesktopShortcutInput) => void) => {
|
||||
listeners.add(listener); return () => { listeners.delete(listener) }
|
||||
}, closeWindow: async () => { fixture.closedWindows++ } },
|
||||
} })
|
||||
}, marker)
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const deliverPrimary = (code: string) => page.evaluate((input) => {
|
||||
window.shortcutFixture.deliver(input)
|
||||
}, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code,
|
||||
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false,
|
||||
} satisfies DesktopShortcutInput)
|
||||
const openReference = () => deliverPrimary('Slash')
|
||||
const group = page.getByRole('treeitem').first()
|
||||
await group.waitFor()
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.focus()
|
||||
await page.keyboard.insertText('Desktop focus draft')
|
||||
const selectDraft = () => composer.evaluate((element) => {
|
||||
const text = element.querySelector('[data-lexical-text]')!.firstChild!
|
||||
document.getSelection()!.setBaseAndExtent(text, 0, text, 7)
|
||||
})
|
||||
await selectDraft()
|
||||
await deliverPrimary('KeyP')
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await files.waitFor()
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
await composer.focus()
|
||||
await selectDraft()
|
||||
await deliverPrimary('KeyP')
|
||||
expect(await files.count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
await deliverPrimary('KeyW')
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0)
|
||||
expect(await composer.innerText()).toBe('Desktop focus draft')
|
||||
await writeComposerDraft(page, composer, '')
|
||||
await expect.poll(() => composer.textContent()).toBe('')
|
||||
await openReference()
|
||||
const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await dialog.waitFor()
|
||||
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const modalState = () => page.evaluate(() => {
|
||||
const name = (node: Element | null) => node?.getAttribute('aria-label')
|
||||
?? document.getElementById(node?.getAttribute('aria-labelledby') ?? '')?.textContent ?? null
|
||||
return {
|
||||
focused: name(document.activeElement?.closest('[role="dialog"]') ?? null),
|
||||
foreground: name(document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.closest('[role="dialog"]') ?? null),
|
||||
}
|
||||
})
|
||||
await deliverPrimary('Comma')
|
||||
await settings.waitFor()
|
||||
const settingsOnTop = await modalState()
|
||||
expect(settingsOnTop).toEqual({ focused: 'Settings', foreground: 'Settings' })
|
||||
await page.keyboard.press('Escape')
|
||||
await settings.waitFor({ state: 'hidden' })
|
||||
expect(await dialog.isVisible()).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await deliverPrimary('Comma')
|
||||
await settings.waitFor()
|
||||
await openReference()
|
||||
await dialog.waitFor()
|
||||
const referenceOnTop = await modalState()
|
||||
expect(referenceOnTop).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' })
|
||||
await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'),
|
||||
`# Modal layers\n\n${JSON.stringify({ settingsOnTop, referenceOnTop }, null, 2)}`, mode)
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
expect(await settings.isVisible()).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await settings.waitFor({ state: 'hidden' })
|
||||
await openReference()
|
||||
await dialog.waitFor()
|
||||
const rowHeights = () => dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))
|
||||
const heights = await rowHeights()
|
||||
expect(new Set(heights)).toEqual(new Set([42]))
|
||||
await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.shortcutFixture.recording)).toBe(true)
|
||||
expect(await rowHeights()).toEqual(heights)
|
||||
const firstRecorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true })
|
||||
expect(await firstRecorder.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none')
|
||||
expect(await dialog.getByRole('button', { name: 'Cancel recording', exact: true }).count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(expected, 'recording.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode)
|
||||
await page.keyboard.press(`${primary}+C`)
|
||||
await dialog.getByRole('group').waitFor({ state: 'hidden' })
|
||||
const saved: unknown = JSON.parse(await readFile(join(userData, 'keybindings.json'), 'utf8'))
|
||||
expect(saved).toMatchObject({ schemaVersion: 2, profiles: { [`desktop:${platform}`]: {
|
||||
'session.new': { code: 'KeyC', modifiers: [platform === 'macos' ? 'meta' : 'control'] },
|
||||
} } })
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
await openReference()
|
||||
await dialog.getByRole('searchbox').fill('New Session')
|
||||
const row = dialog.getByRole('listitem')
|
||||
await expect.poll(() => row.count()).toBe(1)
|
||||
await compareOrRefreshGolden(join(expected, `${platform}-copy-override.expected.md`), await row.ariaSnapshot(), mode)
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await composer.focus()
|
||||
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyC',
|
||||
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false,
|
||||
} satisfies DesktopShortcutInput)
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
|
||||
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
await openReference()
|
||||
await dialog.getByRole('searchbox').fill('New Session')
|
||||
for (const code of ['a', 'F1', 'ArrowLeft', 'Tab']) {
|
||||
await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
|
||||
const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true })
|
||||
await expect.poll(() => recorder.isEnabled()).toBe(true)
|
||||
await recorder.focus()
|
||||
await page.keyboard.press(code)
|
||||
await dialog.getByRole('group').waitFor({ state: 'hidden' })
|
||||
expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({
|
||||
code: code === 'a' ? 'KeyA' : code, modifiers: [],
|
||||
})
|
||||
}
|
||||
await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
|
||||
const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true })
|
||||
await expect.poll(() => recorder.isEnabled()).toBe(true)
|
||||
await recorder.focus()
|
||||
const accepted = snapshot.document
|
||||
for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) {
|
||||
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
const keys = key.split('+')
|
||||
for (const held of keys) await page.keyboard.down(held)
|
||||
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false')
|
||||
expect(await recorder.textContent()).not.toBe('Press a shortcut')
|
||||
for (const held of keys.toReversed()) await page.keyboard.up(held)
|
||||
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('true')
|
||||
if (key === `${primary}+Slash`) {
|
||||
await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor()
|
||||
}
|
||||
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
expect(snapshot.document).toEqual(accepted)
|
||||
expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height)))
|
||||
.toEqual([42])
|
||||
}
|
||||
await compareOrRefreshGolden(join(expected, 'fixed-conflict.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode)
|
||||
const preferencePath = join(userData, 'keybindings.json')
|
||||
const savedPreferencePath = join(userData, 'keybindings.saved.json')
|
||||
await rename(preferencePath, savedPreferencePath)
|
||||
try {
|
||||
// A directory prevents atomic file replacement on every supported filesystem.
|
||||
await mkdir(preferencePath)
|
||||
await page.keyboard.press('j')
|
||||
await dialog.getByText('Could not save. Your previous shortcuts and current draft are preserved. Please retry.', { exact: true }).waitFor()
|
||||
expect(snapshot.document).toEqual(accepted)
|
||||
expect(await recorder.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
} finally {
|
||||
await rm(preferencePath, { recursive: true, force: true })
|
||||
await rename(savedPreferencePath, preferencePath)
|
||||
}
|
||||
await page.keyboard.down('b')
|
||||
await page.keyboard.down('a')
|
||||
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false')
|
||||
expect(await recorder.textContent()).toContain('A')
|
||||
expect(await recorder.textContent()).toContain('B')
|
||||
await page.keyboard.up('a')
|
||||
await page.keyboard.up('b')
|
||||
await dialog.getByRole('group').waitFor({ state: 'hidden' })
|
||||
expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({ code: 'KeyA', secondCode: 'KeyB', modifiers: [] })
|
||||
await compareOrRefreshGolden(join(expected, 'chord.expected.md'), await dialog.getByRole('listitem').ariaSnapshot(), mode)
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
await composer.focus()
|
||||
await page.keyboard.press('a'); await page.keyboard.press('b')
|
||||
await expect.poll(() => composer.textContent()).toBe('ab')
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).isVisible()).toBe(true)
|
||||
await page.keyboard.down('a')
|
||||
await expect.poll(() => composer.textContent()).toBe('aba')
|
||||
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA', secondCode: 'KeyB',
|
||||
control: false, meta: false, alt: false, shift: false, repeat: false,
|
||||
} satisfies DesktopShortcutInput)
|
||||
await page.keyboard.up('a')
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
|
||||
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally { await browser.close() }
|
||||
} finally { await scaffold.close() }
|
||||
} finally {
|
||||
persistence.dispose()
|
||||
await rm(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
/** Fixed approval keys through the real browser, Remote waterfall and recorded model command. */
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/approval-composer/session.v3.jsonl', import.meta.url))
|
||||
|
||||
describe('web e2e: fixed approval rejection keys', () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
let page: Page
|
||||
afterEach(async () => {
|
||||
const results = await Promise.allSettled([browser?.close(), scaffold?.close()])
|
||||
browser = undefined
|
||||
scaffold = undefined
|
||||
const failed = results.filter(result => result.status === 'rejected')
|
||||
if (failed.length > 0) throw new AggregateError(failed.map((result): unknown => result.reason), 'fixed shortcut teardown failed')
|
||||
})
|
||||
|
||||
it.skipIf(webSnapshotMode() === 'record').each(['reject-button-enter', 'container-escape'] as const)(
|
||||
'rejects through %s without running the requested write', async (method) => {
|
||||
const events: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.locator('[aria-label^="Access mode"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await page.locator('[aria-label="Access mode, current: Read Only"]').waitFor()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const prompts = fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
|
||||
expect(prompts).toHaveLength(1)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(prompts[0]!)
|
||||
await input.press('Enter')
|
||||
const panel = page.locator('[data-approval-key]')
|
||||
await panel.waitFor()
|
||||
if (method === 'reject-button-enter') {
|
||||
await panel.getByRole('button', { name: 'Reject', exact: true }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
} else {
|
||||
await panel.locator('[data-approval-scroll]').focus()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await settled
|
||||
expect(events.filter(event => event.type === 'approval/decided').map(event => event.data))
|
||||
.toMatchObject([{ outcome: 'rejected' }])
|
||||
expect(events.filter(event => event.type === 'turn/end').map(event => event.data.reason.kind))
|
||||
.toEqual(['completed'])
|
||||
await expect(access(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await panel.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,254 +0,0 @@
|
||||
/** Sidebar commands through the shipped Loader, real sidebar/file/terminal owners, and Web keybinding editor. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace } from './support.ts'
|
||||
|
||||
const expected = fileURLToPath(new URL('./expected/shortcuts-panels', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
/** Reassign a spare Web combination through the actual editor, leaving browser-reserved defaults untouched. */
|
||||
async function bind(page: Page, primary: string, label: string, previous?: string): Promise<void> {
|
||||
await page.keyboard.press(`${primary}+/`)
|
||||
const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await dialog.waitFor()
|
||||
if (label === 'Split' || label === 'Toggle panel fullscreen') {
|
||||
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }) })
|
||||
expect(await row.getByText(label, { exact: true }).count()).toBe(1)
|
||||
expect(await row.getByText('Focus a right sidebar pane first', { exact: true }).count()).toBe(0)
|
||||
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
|
||||
}
|
||||
if (previous !== undefined) {
|
||||
const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true })
|
||||
await remove.click()
|
||||
await remove.waitFor({ state: 'hidden' })
|
||||
}
|
||||
await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
|
||||
const recorder = dialog.getByRole('group', { name: label, exact: true })
|
||||
await recorder.waitFor()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await recorder.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'detached' })
|
||||
}
|
||||
|
||||
// The fixture owns a POSIX shell; Windows has a separate physical-input acceptance run.
|
||||
describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
|
||||
let browser: Browser
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch()
|
||||
}, 120_000)
|
||||
afterAll(async () => { await browser?.close() })
|
||||
|
||||
it.each([
|
||||
{ platform: 'MacIntel', primary: 'Meta', aria: 'Shift+Meta+,' },
|
||||
{ platform: 'Win32', primary: 'Control', aria: 'Control+Shift+,' },
|
||||
].flatMap(platform => (['left', 'right'] as const).map(closeFirst => ({ ...platform, closeFirst }))))('targets panes and closes $closeFirst first with $platform bindings', async ({ platform, primary, aria, closeFirst }) => {
|
||||
const scaffold: WebScaffold = await launchWebScaffold({
|
||||
extraOverlayPath: [
|
||||
fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
|
||||
fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
|
||||
],
|
||||
})
|
||||
const context = await browser.newContext({ locale: 'en-US', timezoneId: 'Asia/Shanghai', viewport: { width: 1680, height: 1000 } })
|
||||
try {
|
||||
// Device-label/DOM branch evidence only; physical Windows input has its own acceptance checklist.
|
||||
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
|
||||
const page = await context.newPage()
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const agent = scaffold.ctx.agents.list().find(agent => !before.has(agent.id))
|
||||
if (agent === undefined) throw new Error('The workspace did not create a Session')
|
||||
// A settled fixture activates the ordinary Session UI; this scenario issues no model requests.
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Prepare panel actions.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
agent.session.append('assistant/message', { turn: 1, step: 1, stream: [], message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Panel actions are ready.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' })
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await scaffold.ctx.sessions.flush(agent.session)
|
||||
await page.getByText('Panel actions are ready.', { exact: true }).waitFor()
|
||||
const composer = page.locator('[data-composer-input]')
|
||||
await composer.click()
|
||||
await page.keyboard.insertText('T5 draft remains unsent')
|
||||
await page.keyboard.press(`${primary}+Alt+Enter`)
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
const eventCount = agent.session.seq
|
||||
|
||||
await bind(page, primary, 'Toggle right sidebar')
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
await panel.waitFor()
|
||||
expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
await panel.getByRole('tab').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.waitFor()
|
||||
|
||||
const terminalGuide = panel.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button').first()
|
||||
await terminalGuide.hover()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
await page.mouse.move(10, 10)
|
||||
await terminalGuide.focus()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
|
||||
await bind(page, primary, 'Browser', 'Toggle right sidebar')
|
||||
const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]')
|
||||
await browserGuide.hover()
|
||||
await browserGuide.focus()
|
||||
expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
await composer.click()
|
||||
const selectedDraft = await composer.evaluate((element) => {
|
||||
const text = element.querySelector('[data-lexical-text]')!.firstChild!
|
||||
const selection = document.getSelection()!
|
||||
selection.setBaseAndExtent(text, 0, text, text.textContent!.length)
|
||||
return selection.toString()
|
||||
})
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor()
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe(selectedDraft)
|
||||
await panel.getByPlaceholder('Enter an HTTP(S) address').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Alt+W`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(1)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
|
||||
await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, 'browser.expected.md'),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
await panel.locator('[data-dockkit-tab-close]').last().click()
|
||||
await panel.locator('[data-dockkit-tab-close]').click()
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
await bind(page, primary, 'Workspace files', 'Browser')
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor()
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
|
||||
await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files')
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await page.locator('[data-sidebar-right-panel="fullscreen"]').count()).toBe(0)
|
||||
const filesTab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await filesTab.focus()
|
||||
await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor()
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
expect(agent.session.seq).toBe(eventCount)
|
||||
|
||||
await bind(page, primary, 'Split', 'Toggle panel fullscreen')
|
||||
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
|
||||
const left = panel.locator('[data-dockkit-pane][data-dockkit-column="0"]')
|
||||
const right = panel.locator('[data-dockkit-pane][data-dockkit-column="1"]')
|
||||
const disabled = right.locator('[data-dockkit-split-button]')
|
||||
expect(await disabled.isDisabled()).toBe(true)
|
||||
await disabled.locator('..').focus()
|
||||
const splitKeys = primary === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'
|
||||
await page.getByRole('tooltip', { name: `Two panes is the limit ${splitKeys}`, exact: true }).waitFor()
|
||||
|
||||
await bind(page, primary, 'Workspace files', 'Split')
|
||||
await right.getByRole('tab').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(2)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
|
||||
|
||||
await bind(page, primary, 'New terminal', 'Workspace files')
|
||||
await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await right.locator('[data-sidebar-terminal]').waitFor()
|
||||
await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-')
|
||||
await right.locator('.xterm-helper-textarea').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => right.getByRole('tab').count()).toBe(3)
|
||||
await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-')
|
||||
expect(await left.getByRole('tab').count()).toBe(1)
|
||||
await compareOrRefreshGolden(join(expected, 'panels.expected.md'),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
await bind(page, primary, 'Refresh current page', 'New terminal')
|
||||
await right.locator('.xterm-helper-textarea').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await right.getByRole('tab').count()).toBe(3)
|
||||
await left.getByRole('tab', { name: /Files/ }).click()
|
||||
await writeFile(join(scaffold.workspaceCwd, 'workspace', 't6-refresh.txt'), 'page refresh evidence\n')
|
||||
await left.getByRole('tab', { name: /Files/ }).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await left.getByText('t6-refresh.txt', { exact: true }).waitFor()
|
||||
expect(await left.getByRole('button', { name: 'Reload', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
await bind(page, primary, 'Close current page or window', 'Refresh current page')
|
||||
await right.getByRole('tab').last().focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => right.getByRole('tab').count()).toBe(2)
|
||||
expect(await left.getByRole('tab').count()).toBe(1)
|
||||
await panel.getByRole('button', { name: 'Exit fullscreen', exact: true }).click()
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await right.getByRole('tab').count()).toBe(2)
|
||||
await compareOrRefreshGolden(join(expected, 'page-actions.expected.md'),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
const menuTab = left.getByRole('tab', { name: /Files/ })
|
||||
await menuTab.locator('[data-dockkit-tab-close]').focus()
|
||||
await page.getByRole('tooltip').filter({ hasText: 'Close' }).waitFor()
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await menuTab.click({ button: 'right' })
|
||||
await page.getByRole('menu').waitFor()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull()
|
||||
if (attempt === 0) {
|
||||
await compareOrRefreshGolden(join(expected, 'tab-menu.expected.md'),
|
||||
await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode)
|
||||
} else {
|
||||
await page.getByRole('menuitem').focus()
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menu').waitFor({ state: 'detached' })
|
||||
expect(await left.getByRole('tab').count()).toBe(1)
|
||||
expect(await right.getByRole('tab').count()).toBe(2)
|
||||
}
|
||||
expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true)
|
||||
await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab').count()).toBe(2)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab').count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click()
|
||||
await panel.locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
|
||||
await (closeFirst === 'left' ? left : right).getByRole('tab').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab').count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, 'guide-close.expected.md'),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
expect(agent.session.seq).toBe(eventCount)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally { try { await context.close() } finally { await scaffold.close() } }
|
||||
})
|
||||
})
|
||||
@@ -1,311 +0,0 @@
|
||||
/** Workspace keyboard commands over the real Web composition and a recorded Session. */
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, readPersistedEvents,
|
||||
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace } from './support.ts'
|
||||
|
||||
const root = fileURLToPath(new URL('./expected/shortcuts-workspace', import.meta.url))
|
||||
const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
const sourceId = SessionId('shortcuts-workspace-source')
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let previous: string | undefined
|
||||
const launched: { app: string; path: string }[] = []
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ developerTools: false, openInAppEnvironment: createLaunchEnvironmentSnapshot([]) })
|
||||
await seedSession(scaffold, await readFile(seed, 'utf8'), sourceId)
|
||||
await mkdir(join(scaffold.workspaceCwd, 'keyboard-project'))
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'platform', { value: 'MacIntel' })
|
||||
localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode'))
|
||||
})
|
||||
page = await context.newPage()
|
||||
// OS application discovery and launch are the external boundary; the command and HTTP carrier stay real.
|
||||
await page.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: ['finder', 'vscode'] } }))
|
||||
await page.route('**/open-in-app/open', async (route) => {
|
||||
launched.push(route.request().postDataJSON() as { app: string; path: string })
|
||||
await route.fulfill({ json: { ok: true } })
|
||||
})
|
||||
const startup = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const group = page.getByRole('treeitem').first()
|
||||
await group.waitFor({ timeout: 10_000 }).catch((error: unknown) => { throw new Error(JSON.stringify(startup), { cause: error }) })
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
})
|
||||
afterAll(async () => { await browser?.close(); await scaffold?.close() })
|
||||
|
||||
async function bind(label: string): Promise<void> {
|
||||
await page.keyboard.press('Meta+Slash')
|
||||
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await reference.waitFor()
|
||||
if (['Rename session', 'Fork session', 'Archive session'].includes(label)) {
|
||||
const row = reference.getByRole('listitem').filter({ has: page.getByText(label, { exact: true }) })
|
||||
expect(await row.count()).toBe(1)
|
||||
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
|
||||
}
|
||||
if (previous !== undefined) {
|
||||
await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click()
|
||||
await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0)
|
||||
}
|
||||
await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => reference.getByRole('group').count()).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await reference.waitFor({ state: 'hidden' })
|
||||
previous = label
|
||||
}
|
||||
|
||||
it('runs seven owner operations with saved bindings and preserves the completed fork prefix', async () => {
|
||||
const console = watchConsole(page)
|
||||
await bind('Search sessions')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
const search = page.getByPlaceholder('Search session names', { exact: true })
|
||||
await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await page.getByRole('button', { name: 'Search sessions', exact: true }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await bind('Rename session')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
const rename = page.getByRole('dialog', { name: 'Rename session', exact: true })
|
||||
await rename.getByLabel('Session name').fill('T4 source')
|
||||
await rename.getByRole('button', { name: 'Rename', exact: true }).click()
|
||||
await rename.waitFor({ state: 'hidden' })
|
||||
await expect.poll(async () => (await readPersistedEvents(scaffold, sourceId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source')).toBe(true)
|
||||
|
||||
await bind('Fork session')
|
||||
const original = await readPersistedEvents(scaffold, sourceId)
|
||||
const lastEnd = original.findLastIndex(event => event.type === 'turn/end')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)).toBeDefined()
|
||||
const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)!
|
||||
const childId = child.session.header.id
|
||||
await expect.poll(async () => (await readPersistedEvents(scaffold, childId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source (1)')).toBe(true)
|
||||
const copied = await readPersistedEvents(scaffold, childId)
|
||||
expect(copied.slice(0, lastEnd + 1)).toEqual(original.slice(0, lastEnd + 1))
|
||||
const sourceRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) })
|
||||
const childRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source (1)', { exact: true }) })
|
||||
await childRow.waitFor()
|
||||
expect(await sourceRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(await childRow.getAttribute('aria-selected')).toBe('false')
|
||||
await compareOrRefreshGolden(join(root, 'fork.expected.md'),
|
||||
await captureStableAria(page, '[data-slot="sidebar.workspaces"]', scaffold.workspaceCwd), mode)
|
||||
|
||||
await childRow.click()
|
||||
await expect.poll(() => childRow.getAttribute('aria-selected')).toBe('true')
|
||||
await bind('Archive session')
|
||||
await sourceRow.hover()
|
||||
await page.getByRole('button', { name: 'Session actions for T4 source', exact: true }).click()
|
||||
await page.getByRole('menu').waitFor()
|
||||
await compareOrRefreshGolden(join(root, 'session-menu.expected.md'),
|
||||
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), mode)
|
||||
expect(await page.getByRole('menuitem', { name: /^Archive session/ }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menu').waitFor({ state: 'hidden' })
|
||||
await page.getByRole('treeitem', { selected: true }).filter({ hasText: 'T4 source (1)' }).waitFor()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds).toContain(childId)
|
||||
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds).not.toContain(sourceId)
|
||||
expect((await readPersistedEvents(scaffold, sourceId)).length).toBeGreaterThan(0)
|
||||
|
||||
await bind('Add workspace')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
const picker = page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true })
|
||||
await picker.waitFor()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
expect(await page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true }).count()).toBe(1)
|
||||
await picker.getByRole('button', { name: 'Edit path', exact: true }).click()
|
||||
await picker.getByRole('textbox', { name: 'Edit path', exact: true }).fill(join(scaffold.workspaceCwd, 'keyboard-project'))
|
||||
await page.keyboard.press('Enter')
|
||||
await picker.getByRole('button', { name: 'Open', exact: true }).click()
|
||||
await picker.waitFor({ state: 'hidden' })
|
||||
await expect.poll(() => scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project'))).toBeDefined()
|
||||
const workspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project')))!
|
||||
|
||||
const workspaceLabel = basename(workspace.path)
|
||||
const workspaceRow = page.locator('[role="treeitem"][aria-expanded]')
|
||||
.filter({ has: page.getByText(workspaceLabel, { exact: true }) })
|
||||
const details = page.getByRole('button', { name: `Copy: ${workspace.path}`, exact: true })
|
||||
await workspaceRow.getByText(workspaceLabel, { exact: true }).hover()
|
||||
await details.waitFor()
|
||||
await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover()
|
||||
await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor()
|
||||
await expect.poll(() => details.count()).toBe(0)
|
||||
await workspaceRow.getByText(workspaceLabel, { exact: true }).hover()
|
||||
await details.waitFor()
|
||||
await page.mouse.move(700, 100)
|
||||
|
||||
await bind('New Session')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => page.getByRole('treeitem', { selected: true }).textContent()).toContain('New Session')
|
||||
expect(workspace.sessionIds.length).toBe(1)
|
||||
|
||||
await bind('Open locally')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }])
|
||||
expect(console.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries from the completed prefix', async () => {
|
||||
const scaffold = await launchWebScaffold({ developerTools: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
|
||||
const page = await context.newPage()
|
||||
const tripwire = watchConsole(page)
|
||||
const forkWarnings: string[] = []
|
||||
page.on('console', (message) => { if (message.text().startsWith('session fork rejected:')) forkWarnings.push(message.text()) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const source = scaffold.ctx.agents.list().find(agent => !before.has(agent.id))
|
||||
if (source === undefined) throw new Error('The workspace did not create a Session')
|
||||
source.session.append('turn/start', { turn: 1 })
|
||||
const user = source.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'The first turn has not ended.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
source.session.append('session/title', { title: 'First turn', messageSeqs: [user.seq], source: { kind: 'fallback' } })
|
||||
await scaffold.ctx.sessions.flush(source.session)
|
||||
await page.getByText('The first turn has not ended.', { exact: true }).waitFor()
|
||||
const selected = page.getByRole('treeitem', { selected: true })
|
||||
await selected.filter({ hasText: 'First turn' }).waitFor()
|
||||
await page.keyboard.press('Meta+Slash')
|
||||
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await reference.getByRole('button', { name: 'Edit shortcut for Fork session', exact: true }).click()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await reference.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await reference.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
const unavailable = page.getByRole('alert').filter({ hasText: 'This session has no completed turn' })
|
||||
await unavailable.waitFor()
|
||||
await compareOrRefreshGolden(join(root, 'fork-unavailable.expected.md'),
|
||||
await unavailable.ariaSnapshot(), mode)
|
||||
expect(await selected.textContent()).toContain('First turn')
|
||||
expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === source.id)).toHaveLength(0)
|
||||
expect(forkWarnings).toEqual([])
|
||||
|
||||
source.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const completed = source.session.snapshotEvents()
|
||||
source.session.append('turn/start', { turn: 2 })
|
||||
source.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'The second turn has not ended.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await scaffold.ctx.sessions.flush(source.session)
|
||||
await page.getByText('The second turn has not ended.', { exact: true }).waitFor()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)).toBeDefined()
|
||||
const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)!
|
||||
const childRow = page.getByRole('treeitem').filter({ has: page.getByText('First turn (1)', { exact: true }) })
|
||||
await childRow.waitFor()
|
||||
expect(await childRow.getAttribute('aria-selected')).toBe('false')
|
||||
expect(await selected.getByText('First turn', { exact: true }).count()).toBe(1)
|
||||
expect((await readPersistedEvents(scaffold, child.id)).slice(0, completed.length)).toEqual(completed)
|
||||
await page.getByText('The second turn has not ended.', { exact: true }).waitFor()
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(mode === 'record').each([
|
||||
{ platform: 'Win32', primary: 'Control', file: '../shortcuts/en-US', custom: 'Control+Alt+Shift+Meta+J' },
|
||||
{ platform: 'MacIntel', primary: 'Meta', file: 'macos-defaults', custom: 'Meta+Alt+Shift+J' },
|
||||
])('runs $platform defaults over recorded history and restores them after a multi-modifier binding', async ({ platform, primary, file, custom }) => {
|
||||
const scaffold = await launchWebScaffold({ developerTools: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('shortcut-defaults-source'))
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
|
||||
const page = await context.newPage()
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const group = page.getByRole('treeitem').first()
|
||||
await group.waitFor()
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
await page.keyboard.press(`${primary}+Slash`)
|
||||
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await reference.waitFor()
|
||||
await compareOrRefreshGolden(join(root, `${file}.expected.md`),
|
||||
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
|
||||
await page.keyboard.press('Escape')
|
||||
await reference.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press(`${primary}+Alt+K`)
|
||||
const search = page.getByPlaceholder('Search session names', { exact: true })
|
||||
await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press(`${primary}+Alt+B`)
|
||||
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
|
||||
await page.keyboard.press(`${primary}+Alt+B`)
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor()
|
||||
await page.keyboard.press(`${primary}+Slash`)
|
||||
await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
|
||||
await page.keyboard.press(custom)
|
||||
await reference.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press(custom)
|
||||
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
|
||||
await page.keyboard.press(custom)
|
||||
await page.keyboard.press(`${primary}+Slash`)
|
||||
await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
|
||||
await reference.getByRole('button', { name: 'Restore default', exact: true }).click()
|
||||
await reference.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')).toBe(primary === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
|
||||
if (platform === 'MacIntel') {
|
||||
await page.keyboard.press('Meta+Slash')
|
||||
await reference.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
|
||||
const recorder = reference.getByRole('button', { name: 'Press a shortcut', exact: true })
|
||||
// Reproduce the macOS Dead-key payload through the real recorder and command owners.
|
||||
expect(await recorder.evaluate((element) => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true })
|
||||
element.dispatchEvent(event)
|
||||
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft', bubbles: true }))
|
||||
return event.defaultPrevented
|
||||
})).toBe(true)
|
||||
await reference.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.focus()
|
||||
expect(await composer.evaluate((element) => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true })
|
||||
element.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
})).toBe(true)
|
||||
} else {
|
||||
await page.keyboard.press(`${primary}+Alt+N`)
|
||||
}
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
|
||||
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
|
||||
await page.locator('[data-composer-input]').first().waitFor()
|
||||
expect(console.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
@@ -1,336 +0,0 @@
|
||||
/** Real Web composition: keyboard routing, inline editing, and localized system feedback. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { openSettingsFromAccountMenu } from './support.ts'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const expected = fileURLToPath(new URL('./expected/shortcuts', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
describe('web e2e: shortcut reference', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ developerTools: false })
|
||||
browser = await chromium.launch()
|
||||
}, 120_000)
|
||||
afterAll(async () => { await browser?.close(); await scaffold?.close() })
|
||||
|
||||
it.each([
|
||||
{ locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' },
|
||||
{ locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' },
|
||||
])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => {
|
||||
const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash'
|
||||
const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } })
|
||||
try {
|
||||
// Branch coverage for device labels; native Windows input is separately verified on Windows.
|
||||
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
|
||||
const page = await context.newPage()
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const trigger = page.getByRole('button', { name: locale === 'zh-CN' ? '账号菜单' : 'Account menu', exact: true })
|
||||
const settingsItem = page.getByRole('menuitem', { name: settings, exact: true })
|
||||
await trigger.click()
|
||||
expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`)
|
||||
expect(await settingsItem.locator('kbd').allTextContents()).toEqual(key === 'Meta' ? ['⌘', ','] : ['Ctrl', '+', ','])
|
||||
await settingsItem.click()
|
||||
await page.getByRole('button', { name: view, exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: title, exact: true })
|
||||
await dialog.waitFor()
|
||||
const referenceNode = await dialog.elementHandle()
|
||||
expect(await page.getByRole('dialog').count()).toBe(2)
|
||||
expect(await dialog.locator('footer').count()).toBe(1)
|
||||
const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true })
|
||||
expect(await resetAll.isDisabled()).toBe(true)
|
||||
expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 })
|
||||
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 })
|
||||
const rows = dialog.getByRole('listitem')
|
||||
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
|
||||
.toEqual(new Set([42]))
|
||||
expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42
|
||||
&& row.scrollWidth <= row.clientWidth))).toBe(true)
|
||||
expect(await page.getByRole('searchbox', { name: search }).evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, `${locale}.expected.md`),
|
||||
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
|
||||
const fixedButtons = dialog.getByRole('button', { disabled: true })
|
||||
const fixedButton = fixedButtons.first()
|
||||
expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45')
|
||||
const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await fixedButton.hover()
|
||||
expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
|
||||
const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar'
|
||||
const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar'
|
||||
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) })
|
||||
const editButton = page.getByRole('button', { name: editLabel, exact: true })
|
||||
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
|
||||
const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText(
|
||||
locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true },
|
||||
) })
|
||||
const badge = boundRow.getByRole('button').last()
|
||||
const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
const restingColor = await badgeColor()
|
||||
await boundRow.locator(':scope > span').first().hover()
|
||||
expect(await badgeColor()).toBe(restingColor)
|
||||
await badge.hover()
|
||||
expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)')
|
||||
await boundRow.getByRole('button').first().hover()
|
||||
expect(await badgeColor()).toBe(restingColor)
|
||||
await badge.click()
|
||||
await boundRow.getByRole('group').waitFor()
|
||||
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
|
||||
.toEqual(new Set([42]))
|
||||
expect(await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await dialog.getByRole('heading', { name: title, exact: true }).hover()
|
||||
const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity)
|
||||
expect(await boundActionsOpacity()).toBe('0')
|
||||
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await badge.click()
|
||||
await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
|
||||
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await dialog.getByRole('heading', { name: title, exact: true }).hover()
|
||||
expect(await boundActionsOpacity()).toBe('0')
|
||||
await row.hover()
|
||||
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1')
|
||||
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1)
|
||||
await editButton.click()
|
||||
const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true })
|
||||
const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true })
|
||||
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
|
||||
expect(await page.getByRole('dialog').count()).toBe(2)
|
||||
expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
|
||||
await compareOrRefreshGolden(join(expected, `${locale}-inline.expected.md`),
|
||||
await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode)
|
||||
await page.keyboard.press(referenceKey)
|
||||
const errorToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已被「快捷键速查」占用' : 'Already used by “Open keyboard shortcuts”' })
|
||||
await errorToast.waitFor()
|
||||
expect(await errorToast.evaluate(element => ({
|
||||
background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color,
|
||||
})))
|
||||
.toEqual({ background: 'rgb(53, 54, 56)', color: 'rgb(255, 255, 255)' })
|
||||
expect(await errorToast.evaluate(element => element.closest('[role="dialog"]') === null)).toBe(true)
|
||||
expect(await recorder.evaluate(element => ({
|
||||
color: getComputedStyle(element).borderColor, width: getComputedStyle(element).borderWidth,
|
||||
})))
|
||||
.toEqual({ color: 'rgb(236, 19, 19)', width: '1px' })
|
||||
await compareOrRefreshGolden(join(expected, `${locale}-conflict.expected.md`), await errorToast.ariaSnapshot(), mode)
|
||||
await page.keyboard.press('i')
|
||||
await page.getByRole('alert').filter({ hasText: locale === 'zh-CN'
|
||||
? '请同时按下 Command、Ctrl 或 Alt 修饰键。' : 'Include Command, Ctrl, or Alt in the combination.' }).waitFor()
|
||||
await page.keyboard.press(`${key}+Shift+.`)
|
||||
await inline.waitFor({ state: 'hidden' })
|
||||
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false)
|
||||
await dialog.getByRole('heading', { name: title, exact: true }).hover()
|
||||
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
|
||||
const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' })
|
||||
await successToast.waitFor()
|
||||
await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode)
|
||||
await page.emulateMedia({ colorScheme: 'dark' })
|
||||
await expect.poll(() => successToast.evaluate(element => ({
|
||||
background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color,
|
||||
}))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' })
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)')
|
||||
await page.getByRole('button', { name: removeLabel, exact: true }).click()
|
||||
const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true })
|
||||
await unbound.waitFor()
|
||||
expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
|
||||
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0)
|
||||
await unbound.click()
|
||||
await inline.waitFor()
|
||||
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await inline.waitFor({ state: 'hidden' })
|
||||
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await dialog.getByRole('heading', { name: title, exact: true }).hover()
|
||||
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
|
||||
expect(await page.getByRole('dialog').count()).toBe(2)
|
||||
for (const query of ['abc', 'sendEnter']) {
|
||||
await page.getByRole('searchbox').fill(query)
|
||||
expect(await dialog.getByRole('listitem').count()).toBe(0)
|
||||
expect(await dialog.getByRole('status').textContent()).toBe(locale === 'zh-CN' ? '没有匹配的快捷键' : 'No matching shortcuts')
|
||||
if (query === 'abc') await compareOrRefreshGolden(join(expected, `${locale}-empty.expected.md`),
|
||||
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
|
||||
}
|
||||
for (const query of [locale === 'zh-CN' ? '左侧栏' : 'toggle left', 'tgllft', 'toggle left sidebar']) {
|
||||
await page.getByRole('searchbox').fill(query)
|
||||
expect(await dialog.getByRole('listitem').count()).toBe(1)
|
||||
expect(await dialog.getByRole('listitem').textContent()).toContain(locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar')
|
||||
}
|
||||
await page.keyboard.press(referenceKey)
|
||||
expect(await referenceNode!.evaluate(element => element.isConnected)).toBe(true)
|
||||
expect(await page.getByRole('dialog').count()).toBe(2)
|
||||
expect(await page.getByRole('searchbox').inputValue()).toBe('toggle left sidebar')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await page.getByRole('dialog').count()).toBe(1)
|
||||
expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await page.getByRole('dialog').count()).toBe(0)
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press(referenceKey)
|
||||
await dialog.waitFor()
|
||||
await page.keyboard.press(`${key}+/`)
|
||||
expect(await page.getByRole('dialog').count()).toBe(1)
|
||||
expect(await page.getByRole('searchbox').inputValue()).toBe('')
|
||||
await page.setViewportSize({ width: 420, height: 500 })
|
||||
const compactBounds = (await dialog.boundingBox())!
|
||||
expect(compactBounds.x).toBeGreaterThanOrEqual(24)
|
||||
expect(compactBounds.x + compactBounds.width).toBeLessThanOrEqual(396)
|
||||
expect(compactBounds.y + compactBounds.height).toBeLessThanOrEqual(476)
|
||||
await dialog.getByRole('region').last().scrollIntoViewIfNeeded()
|
||||
expect(await dialog.getByRole('heading', { name: title }).isVisible()).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
// Observe preventDefault without invoking destructive browser defaults in the test runner.
|
||||
expect(await page.evaluate(modifier => ['KeyN', 'KeyP', 'KeyO', 'KeyR', 'KeyW', 'KeyB'].map((code) => {
|
||||
const event = new KeyboardEvent('keydown', { code, ctrlKey: modifier === 'Control', metaKey: modifier === 'Meta', bubbles: true, cancelable: true })
|
||||
document.body.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}), key)).toEqual(Array<boolean>(6).fill(false))
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await openSettingsFromAccountMenu(page, locale === 'zh-CN' ? 'zh' : 'en')
|
||||
const viewButton = page.getByRole('button', { name: view, exact: true })
|
||||
const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
|
||||
await viewButton.hover()
|
||||
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
|
||||
await viewButton.click()
|
||||
await boundRow.getByRole('button').first().click()
|
||||
await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await viewButton.hover()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
expect(await viewButton.getAttribute('aria-keyshortcuts')).toBeNull()
|
||||
await viewButton.click()
|
||||
await badge.click()
|
||||
await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
|
||||
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await viewButton.hover()
|
||||
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
|
||||
await viewButton.click()
|
||||
await page.getByRole('searchbox').fill('abc')
|
||||
expect(await dialog.getByRole('listitem').count()).toBe(0)
|
||||
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized')
|
||||
await resetAll.click()
|
||||
const confirm = page.getByRole('dialog', { name: locale === 'zh-CN' ? '恢复全部默认快捷键?' : 'Restore all default shortcuts?', exact: true })
|
||||
const cancel = confirm.getByRole('button', { name: locale === 'zh-CN' ? '取消' : 'Cancel', exact: true })
|
||||
expect(await cancel.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, `${locale}-reset-confirm.expected.md`), await confirm.ariaSnapshot(), mode)
|
||||
await cancel.click()
|
||||
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized')
|
||||
await resetAll.click()
|
||||
await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
|
||||
await confirm.waitFor({ state: 'hidden' })
|
||||
expect(await resetAll.isDisabled()).toBe(true)
|
||||
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized')
|
||||
expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press(referenceKey)
|
||||
const settingsCommand = locale === 'zh-CN' ? '打开设置' : 'Open settings'
|
||||
await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click()
|
||||
await page.keyboard.press(`${key}+Shift+Comma`)
|
||||
await dialog.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await trigger.click()
|
||||
expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,')
|
||||
expect(await settingsItem.locator('kbd').allTextContents()).toEqual(key === 'Meta' ? ['⇧', '⌘', ','] : ['Ctrl', '+', 'Shift', '+', ','])
|
||||
await settingsItem.click()
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press(referenceKey)
|
||||
await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click()
|
||||
await dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
|
||||
await dialog.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await trigger.click()
|
||||
expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`)
|
||||
await page.keyboard.press('Escape')
|
||||
const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true })
|
||||
expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await sidebar.waitFor()
|
||||
expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally { await context.close() }
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' },
|
||||
{ platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' },
|
||||
{ platform: 'Linux x86_64', modifier: 'Control', defaultBinding: null },
|
||||
])('records, rejects conflicts, persists across reload, and synchronizes stale drafts between tabs on $platform', async ({ platform, modifier, defaultBinding }) => {
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
try {
|
||||
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
|
||||
const page = await context.newPage()
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor()
|
||||
const referenceKey = `${modifier}+Slash`
|
||||
const open = async () => {
|
||||
await page.keyboard.press(referenceKey)
|
||||
await page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }).waitFor()
|
||||
}
|
||||
await open()
|
||||
await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
|
||||
const editor = page.getByRole('group', { name: 'Toggle left sidebar', exact: true })
|
||||
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
|
||||
await page.keyboard.press(referenceKey)
|
||||
await page.getByRole('alert').filter({ hasText: 'Already used by “Open keyboard shortcuts”' }).waitFor()
|
||||
expect(await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).getAttribute('aria-invalid')).toBe('true')
|
||||
await page.keyboard.press(`${modifier}+Shift+.`)
|
||||
await editor.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
const sidebarKey = `${modifier}+Shift+.`
|
||||
expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts'))
|
||||
.toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.')
|
||||
await page.keyboard.press(sidebarKey)
|
||||
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor()
|
||||
const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ })
|
||||
await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts'))
|
||||
.toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.')
|
||||
const wasOpen = await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).isVisible()
|
||||
await page.keyboard.press(sidebarKey)
|
||||
await page.getByRole('button', { name: wasOpen ? 'Open sidebar' : 'Collapse sidebar', exact: true }).waitFor()
|
||||
if (wasOpen) await page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
|
||||
await open()
|
||||
await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
|
||||
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
|
||||
const other = await context.newPage()
|
||||
await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await other.getByRole('button', { name: 'Account menu', exact: true }).waitFor()
|
||||
await other.keyboard.press(referenceKey)
|
||||
await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click()
|
||||
await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor()
|
||||
await page.bringToFront()
|
||||
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
|
||||
await page.keyboard.press(`${modifier}+Shift+,`)
|
||||
expect(await editor.getByRole('button', { name: 'Retry save', exact: true }).count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(expected, 'edit-stale.expected.md'),
|
||||
await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode)
|
||||
await editor.getByRole('button', { name: 'I have reviewed the latest configuration', exact: true }).click()
|
||||
await editor.getByRole('button', { name: 'Retry save', exact: true }).click()
|
||||
await editor.waitFor({ state: 'hidden' })
|
||||
await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
|
||||
await editor.getByRole('button', { name: 'Restore default', exact: true }).click()
|
||||
await editor.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts'))
|
||||
.toBe(defaultBinding)
|
||||
const stored = await page.evaluate((): unknown => JSON.parse(localStorage.getItem('dsh.keybindings.v1')!))
|
||||
const profile = platform === 'MacIntel' ? 'macos' : platform === 'Win32' ? 'windows' : 'linux'
|
||||
expect(stored).toEqual({ schemaVersion: 1, profiles: { [`web:${profile}`]: {} } })
|
||||
} finally { await context.close() }
|
||||
})
|
||||
|
||||
})
|
||||
Loaded 100 of 766 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user