diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c70365..db2b46d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,10 @@ name: CI on: [push, pull_request] +# Least privilege by default; the deploy job widens this for itself. +permissions: + contents: read + jobs: ci: strategy: @@ -11,6 +15,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: pnpm/action-setup@v6 with: version: 11 @@ -18,7 +24,7 @@ jobs: with: node-version: 24 - - uses: cargo-bins/cargo-binstall@main + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - name: Install wasm-pack shell: bash @@ -35,6 +41,8 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build:wasm - run: pnpm lint + - run: pnpm check + - run: pnpm test - run: pnpm build:tauri - uses: actions/upload-artifact@v7 @@ -48,8 +56,46 @@ jobs: src-tauri/target/release/bundle/**/*.AppImage if-no-files-found: error + # End-to-end against the riscv fixture firmware: real rynk protocol over a + # real UART-to-TCP bridge. Linux-only — it needs qemu, not a desktop bundle. + qemu: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + with: + version: 11 + - uses: actions/setup-node@v7 + with: + node-version: 24 + + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 + + - name: Install toolchains and qemu + run: | + rustup target add wasm32-unknown-unknown riscv32imac-unknown-none-elf + cargo binstall wasm-pack --no-confirm + sudo apt-get update + sudo apt-get install -y qemu-system-misc + + # One clone for both artifacts. The firmware and the wasm client speak one + # protocol, so resolving `main` twice can put them on different commits and + # fail the run on wire errors. RMK_REPO is honoured by both build:wasm and + # qemu/run.mjs. + - name: Check out rmk once + run: | + git clone --depth 1 https://github.com/rmk-rs/rmk.git "$RUNNER_TEMP/rmk" + echo "rmk at $(git -C "$RUNNER_TEMP/rmk" rev-parse HEAD)" + echo "RMK_REPO=$RUNNER_TEMP/rmk" >> "$GITHUB_ENV" + + - run: pnpm install --frozen-lockfile + - run: pnpm build:wasm + - run: pnpm test:qemu + deploy: - needs: ci + needs: [ci, qemu] runs-on: ubuntu-24.04 permissions: contents: read @@ -59,6 +105,8 @@ jobs: github.event_name == 'pull_request' && github.base_ref == 'main' steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: pnpm/action-setup@v6 with: version: 11 @@ -66,7 +114,7 @@ jobs: with: node-version: 24 - - uses: cargo-bins/cargo-binstall@main + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - name: Install wasm-pack run: | diff --git a/README.md b/README.md index f400802..d778b12 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@
-Clash -

-Gui configuration for RMK based on Tauri and Nuxt +RMK GUI

+Gui configuration for RMK based on Tauri and Svelte +

## Warn @@ -14,13 +14,14 @@ safe distance. ## Install -Go to the release page to download the corresponding installation package Supports Windows (x64/x86), Linux (x64/arm64) +Go to the release page to download the corresponding installation package. Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple). ## Features - Based on Rust and Tauri2 frameworks. -- Concise and modern user interface. +- Concise and modern user interface, built with Svelte 5 and Tailwind CSS 4. +- Talks the rynk protocol over USB serial, BLE, and Web Serial. - Support for Windows, macOS, and Linux. ## Development @@ -35,18 +36,53 @@ Make sure you have Rust, NodeJS and Python installed on your system. 2. Install dependencies: ```bash pnpm install + rustup target add wasm32-unknown-unknown + cargo install wasm-pack + ``` +3. Build the protocol client: + ```bash + pnpm build:wasm ``` -3. Start the development server: + `src/rynk/wasm/` is a build artifact and is not checked in, so this step is + required before anything else runs. It compiles `rynk-wasm` from a sibling + `../rmk` checkout when one exists, otherwise it clones `rmk-rs/rmk`; set + `RMK_REPO` to point somewhere else. +4. Start the development server: ```bash - pnpm web:dev - pnpm tauri:dev + pnpm dev:web # browser only + pnpm dev:tauri # desktop app — runs dev:web itself, don't start both ``` -4. Build the application: +5. Build the application: ```bash - pnpm web:build - pnpm tauri:build + pnpm build:web + pnpm build:tauri # runs build:web itself ``` +### Testing + +```bash +pnpm test # unit tests — no device and no wasm build needed +pnpm test:qemu # end-to-end against the riscv fixture firmware +pnpm check # svelte-check +CI=true pnpm lint +``` + +`pnpm test:qemu` builds and runs `qemu/` itself, so there is nothing to start by +hand. It needs `qemu-system-riscv32` (`brew install qemu`, or +`apt install qemu-system-misc` on Debian/Ubuntu), the +`riscv32imac-unknown-none-elf` target, and step 3 to have run. It picks a free +TCP port per run; a manual `pnpm qemu` defaults to 7965 and takes +`RMK_QEMU_PORT`. Don't point both at one port — the fixture's serial port serves +one client at a time. + +The fixture firmware resolves `rmk` the same way step 3 does (`RMK_REPO`, then a +sibling `../rmk`), so the firmware and the wasm client stay on one revision. +With neither, cargo falls back to the `main` branch and the two can drift onto +different protocol commits. + +`CI=true` matters for linting: the eslint config detects editors and relaxes +some rules, so a bare `pnpm lint` is more permissive than CI. + ## Roadmap Too many to write @@ -57,8 +93,9 @@ RMK-GUI was based on or inspired by these projects and so on: - [Tauri](https://github.com/tauri-apps/tauri) A framework for building tiny, fast binaries for all major desktop and mobile platforms. -- [Nuxt](https://github.com/nuxt/nuxt) An open source framework that makes web development intuitive and powerful. -- [PrimeVue](https://github.com/primefaces/primevue) A free open-source Vue 3 UI component library with rich features. +- [Svelte](https://github.com/sveltejs/svelte) A UI framework that compiles components to small, surgical JavaScript. +- [Ark UI](https://github.com/chakra-ui/ark) A headless, accessible component library for building design systems. +- [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) A utility-first CSS framework. - [Vial-gui](https://github.com/vial-kb/vial-gui) An open-source cross-platform (Windows, Linux and Mac) GUI and a QMK fork for configuring your keyboard in real time. - [RMK](https://github.com/rmk-rs/rmk) Rust keyboard firmware library with layers, macros, real-time keymap editing, diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..e060971 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,268 @@ +# rmk-gui 测试方案 + +| | | +|---|---| +| **状态** | 待评审 | +| **范围** | rmk-gui 前端(TypeScript / Svelte)的端到端自动化测试 | + +--- + +## 1. 背景 + +rmk-gui 是 RMK 键盘的图形配置工具,Tauri v2 + Svelte 5。与固件之间说 rynk 协议;协议客户端不在本仓库,而是上游 `rmk-rs/rmk` 的 `rynk-wasm` 编译产物,由 `scripts/build-rynk-wasm.py` 构建后落到 `src/rynk/wasm/`。 + +代码现状:手写约 1060 行 TypeScript/Svelte + 375 行 Rust。核心是 `src/stores/keyboard/keyboard.svelte.ts`(456 行),负责设备数据的读取重排、写入分页、乐观更新回滚与推送分发;`src/rynk/` 是传输与协议引导层;`src/components/` 中只有键盘布局渲染有实质逻辑;`src-tauri/` 提供 serial / BLE / TCP 三种字节通道。 + +仓库当前没有任何自动化测试:无 test runner、无测试文件、无 `test` script,Rust 侧无 `#[cfg(test)]`,CI 仅执行 lint 与 build。 + +## 2. 目标 + +**G1 — 抓住上游协议演进对 rmk-gui 的影响。** `rynk-wasm` 随上游 `rmk-rs/rmk` 持续变化,rmk-gui 对它存在一批隐含假设:扁平键位数组的排列顺序、宏分块的读取语义、推送事件变体的集合、capabilities 各字段的用法、错误码的取值范围。上游改动使这些假设不再成立时,测试必须红。这类破坏当前完全没有防护,且不会在类型检查中暴露。 + +**G2 — 覆盖 rmk-gui 自身的逻辑。** 设备发现与选择、数据重排、写入分页、乐观更新回滚、并发串行化、推送分发、连接拆除顺序、布局渲染几何。 + +## 3. 测试对象 + +被测代码集中在传输引导层(`src/rynk/`)、store(`src/stores/keyboard/`)与键盘布局渲染(`src/components/Keyboard.svelte`),分五类: + +- **发现与连接** —— 枚举可用设备、选择其一、建立会话并完成协议握手。 +- **读取路径** —— 把设备返回的扁平数组、分块数据、按 capabilities 边界的重复查询,整理成 store 的结构化模型。 +- **写入路径** —— 参数校验、整表分页写入,以及乐观更新与失败回滚。 +- **并发与生命周期** —— 写入的串行化、推送事件的分发、连接拆除的顺序。 +- **渲染** —— 布局数据到界面几何的换算(含旋转与包围盒)。 + +选择这五类的依据是失效模式:**它们出错时程序不会崩溃,而是让数据被静默写错**——例如键位展开顺序与回写的拍平顺序不一致时,用户的整张键位表会被打乱,而界面、日志、类型检查均无异常。这类缺陷靠人工使用很难发现,是自动化测试收益最高的部分。 + +`qemu/` 下的模拟固件是端到端链路的设备端。它的几何、键位、编码器映射、预置数据与推送节奏都是确定且已知的,因此可以作为断言的基准——详见 §5.5。 + +## 4. 方案设计 + +### 4.1 总体架构 + +端到端链路,测试宿主为单个 Node 进程,QEMU 是它拉起的另外两个进程(开放固件 + 锁定固件): + +``` +qemu-system-riscv32 ×2 ──TCP──▶ tests/ipc.ts + 开放 / 锁定 (--features locked) │ Tauri invoke 桩 + TCP 会话管理 + ▼ + src/rynk/index.ts discover() (真) + src/rynk/tauri.ts TauriByteLink (真) + ▼ + keyboardStore.initStore(connected) + │ + ├── connectClient() COBS 探测 + 真实 wasm 客户端 + └── store 全链路 重排 / 分页 / 回滚 / topic 分发 + ▼ + mount(Keyboard, { target }) jsdom 真实 DOM +``` + +### 4.2 关键设计决策 + +**D1 — 运行环境选择 Vitest + jsdom,而非真实浏览器** + +| 方案 | 评估 | +|---|---| +| Playwright / Vitest browser mode | 需安装浏览器、起 dev server、跨进程通信;断言 UI 几何时要读渲染盒 | +| **Vitest + jsdom(采纳)** | 单进程;`keyStyle()` 把 `width`/`height`/`left`/`top`/`transform` 直接写入内联 style,jsdom 不做布局也能精确读取,**断言精度反而高于量像素** | +| 纯 Node 无 DOM | 无法覆盖点击选中等交互 | + +**D2 — 以最小 IPC 桩覆盖设备发现与选择,但不测 `src-tauri` 的 Rust 实现** + +设备发现与选择(`src/rynk/index.ts` 的 `discover()`、`src/rynk/tauri.ts` 的连接封装)是 rmk-gui 自己的代码,属于测试对象。它们经 `@tauri-apps/api/core` 的 `invoke` 与 Rust 通信,而 `invoke` 的实现是一行 `window.__TAURI_INTERNALS__.invoke(cmd, args, options)`,`isTauri()` 是 `!!(globalThis || window).isTauri`——这个接缝天然存在,注入两行即可接管,**无需改动任何源码**。 + +因此 `tests/ipc.ts` 只做两件事:向 `discover()` 报告一个 TCP 设备,以及为选中的设备建立到 QEMU 的 TCP 会话。它**不是** `src-tauri` 的等价实现,也不承担验证 Rust transport 的职责——那 375 行 Rust 本方案不覆盖。桩的语义(尤其是 `rynk_recv` 以空数组表示 EOF)需与 Rust 侧一致,否则测的是桩的行为。 + +## 5. 详细设计 + +### 5.1 目录结构 + +``` +vitest.config.ts +tests/ + qemu.ts globalSetup:构建 + 启动两个 QEMU 实例,provide 地址,拆除 + ipc.ts Tauri invoke 桩 + TCP 会话管理 + fixture.ts 固件夹具的已知行为常量(断言基准,见 §5.5) + connect.test.ts 设备发现、选择、连接、能力读取 + store.test.ts 数据读取、写入、校验、回滚、串行化、错误映射 + lifecycle.test.ts topic 推送与分发、连接拆除 + locked.test.ts 锁定固件的连接与锁门行为 + keyboard-ui.test.ts Keyboard.svelte 渲染几何与交互 + smoke.test.ts 构建产物冒烟 +``` + +新增依赖仅 `vitest` 与 `jsdom`;`@sveltejs/vite-plugin-svelte@^7` 已在。 + +### 5.2 运行时配置 + +独立于 `vite.config.ts`(后者携带 tailwind 插件、`strictPort:1420`、依赖 `TAURI_ENV_PLATFORM` 的 build target,均不适用)。 + +```ts +export default defineConfig({ + plugins: [svelte({ dynamicCompileOptions: () => ({ generate: 'client' }) })], + resolve: { + alias: { '~': src, '@': src }, + conditions: [...defaultClientConditions], + }, + ssr: { resolve: { conditions: [...defaultClientConditions] } }, + test: { + environment: 'jsdom', + globalSetup: ['tests/qemu.ts'], + fileParallelism: false, + testTimeout: 20_000, + hookTimeout: 60_000, + }, +}) +``` + +三处配置各有原因,均不可省: + +- `dynamicCompileOptions: generate: 'client'` — vite-plugin-svelte 依据 consumer 推导 generate 模式,Vitest 下会推导为 `'server'`,导致 `.svelte.ts` 中的 `$state` 编译为 no-op。 +- `resolve.conditions` 含 `browser` — `svelte@5.56.8` 的根导出为条件分支(`browser` → `index-client.js`,`default` → `index-server.js`),缺失时取到 SSR 版的 `mount`/`flushSync`。 +- `fileParallelism: false` — QEMU 的 `-serial tcp::PORT,server,nowait` 同时只接受一个客户端。 + +前两条属于**静默失效**:配置缺失不会报错,表现为 `$state` 不响应、测试行为难以解释。落地时应在写任何用例之前先确认二者生效。 + +`tsconfig.json` 的 `include` 追加 `"tests/**/*.ts"`,使 `pnpm check` 覆盖测试代码。 + +**测试隔离**:Vitest 为每个测试文件使用独立模块注册表,`keyboardStore` 单例与模块级 `session` 对象天然跨文件隔离;文件内以 `afterEach(() => keyboardStore.resetStore())` 复位。 + +### 5.3 设备夹具编排(`tests/qemu.ts`) + +1. 若 `RMK_E2E_ADDR` 已设置则直接复用,跳过构建与启动。 +2. 前置检查:`qemu-system-riscv32` 缺失、或 `src/rynk/wasm/rynk_wasm_bg.wasm` 缺失(该目录 gitignore,需先 `pnpm build:wasm`)时,给出可直接执行的修复提示而非底层错误。 +3. 构建两份固件,**使用各自独立的 target 目录**——共用 `qemu/target` 会让两个 feature set 相互触发全量重建: + ``` + cargo build --release --target-dir target/e2e-open + cargo build --release --features locked --target-dir target/e2e-locked + ``` +4. 向 OS 申请两个空闲端口(`net.createServer().listen(0)`)。**不使用 7965**——该端口被 `pnpm qemu` 与 `rynk_discover_tcp`(debug 构建)占用,并行开发时会冲突。 +5. 就绪探测:QEMU 先 bind 后才启动固件,故"端口可连"不足以判定就绪;需每 100 ms 探测,连通后再等待收到首个字节(固件的 `test_topics` 每 200 ms 推送一次,正常在约 250 ms 内出字节)。 +6. 通过 `provide('qemuAddr', ...)` 与 `provide('qemuLockedAddr', ...)` 下发地址。两个实例并发常驻,锁定用例因而只是同一次运行中的普通文件,不需要第二轮串行执行。 +7. 拆除:SIGTERM → 2 s 后 SIGKILL,并注册 `process.on('exit')` 兜底,避免 Vitest 异常退出时遗留进程占用端口。失败时输出 QEMU 的 semihosting 尾部日志。 + +### 5.4 IPC 桩与字节通道(`tests/ipc.ts`) + +注入两行接管 IPC 边界: + +```ts +globalThis.isTauri = true +window.__TAURI_INTERNALS__ = { invoke: (cmd, args) => dispatch(cmd, args) } +``` + +实现的命令及其语义(须与 `src-tauri/src/transport/mod.rs` 一致): + +| 命令 | 行为 | +|---|---| +| `rynk_discover_tcp` | 返回 `[{ addr, name: 'QEMU' }]`,addr 由 globalSetup 下发 | +| `rynk_discover_serial` / `rynk_discover_ble` | 返回 `[]`;另提供可注入抛错的开关,用于验证 `discover()` 的单传输容错 | +| `rynk_connect_tcp` | 建立 socket(`setNoDelay(true)`,回环上 Nagle 会为每次往返增加 40 ms),返回 `{ session, descriptor }` | +| `rynk_send` | 在 socket write callback 中 resolve(wasm 的 `WasmWriter` 依赖此语义) | +| `rynk_recv` | 长轮询:有数据即返回,无数据则挂起;**EOF / error 返回空数组**(断连哨兵) | +| `rynk_close` / `rynk_close_all` | 销毁 socket 并唤醒挂起的 `recv` | +| 未知 session id | `send`/`close` 静默返回 ok,`recv` 返回空数组(与 Rust 侧当前的宽容行为一致) | + +wasm 预初始化:`mod.default({ module_or_path: await readFile(...) })`。此后 `connectClient` 内那句无参 `core.default()` 因 `__wbg_init` 的早返回而成为 no-op,**生产代码无需任何改动**。 + +### 5.5 断言基准(`tests/fixture.ts`) + +端到端测试的价值来自**有确定的期望值可比**。`qemu/` 固件的行为完全确定,因此断言直接使用这些已知值,而不是从被测代码自身推导——后者是自指的:若 rmk-gui 对线路格式的假设本身就错了,推导出的期望值会跟着一起错,测试反而通过。 + +夹具的已知行为,来源为 `qemu/keyboard.toml` 与 `qemu/src/main.rs`: + +| 类别 | 已知值 | +|---|---| +| 矩阵几何 | 4 行 × 12 列,2 层,2 个编码器 | +| 能力开关 | storage / ble / split / lighting 均关闭(crate 以 `default-features = false, features = ["rynk"]` 构建) | +| 键位 layer 0 | QWERTY。`(0,0)=Q`、`(0,11)=P`、`(0,5)`与`(0,6)=No`、`(2,5)=LCtrl`、`(3,0)=Escape`、`(3,8)=LayerOn(1)`、`(3,11)=Enter` | +| 键位 layer 1 | 48 个位置全部 `Transparent` | +| 编码器 | `(0,0)`=音量增/减、`(1,0)`=PageUp/Down、`(0,1)`=Kp+/Kp−、`(1,1)`=Home/End | +| 预置数据 | 1 个 Fork(trigger=A,negative=B,positive=C,bindable);1 个 Morse;combos 全空 | +| 布局 | 42 个键(48 个矩阵位减去未映射的 6 个);左区 `r=+10°`、右区 `r=-10°`;`(2,5)` 与 `(2,6)` 高 1.5u,其余 1u | +| 推送 | 每 200 ms 一轮:LayerChange 在 0/1 间交替、WpmUpdate 每次 +7、LedIndicator、SleepState、ConnectionStatus。**不含** BatteryStatusChange(固件未启用 ble) | +| 锁定固件 | `--features locked`:解锁键位 `(0,0)` 与 `(0,11)`,`insecure = false`,`write_requires_unlock = false` | + +常量集中在 `tests/fixture.ts` 单一文件。夹具变更时只改这一处——这是接受"断言绑定夹具"这一代价的前提。 + +### 5.6 测试用例 + +标注 **G1** 者为捕获上游协议演进的主力(§2);其余服务于 G2。 + +#### `connect.test.ts` — 发现、选择与连接 + +1. **G1 · 设备发现** — `discover()` 返回一个 `kind: 'tcp'`、`label: 'QEMU'` 的条目;调用发生在 `closeAllSessions()` 之后;串口或 BLE 枚举抛错时其余传输仍正常返回(验证 per-transport 的 `.catch(() => [])` 隔离);label 回退规则(`name ?? path` / `name ?? id`)正确。 +2. **键盘选择** — 从 `discover()` 的结果中选定一项并调用其 `connect()`,得到的 `ConnectedDevice` 携带正确的 label 与 descriptor;将其交给 `initStore()` 完成连接。这是应用里"选哪块键盘"的实际路径。 +3. **G1 · 连接与能力** — `connection.phase` 由 `connecting` 变为 `connected`;`capabilities` 与 §5.5 的几何和能力开关逐项相符;`device.info` 的厂商与产品名符合固件声明。 +4. **连接失败** — 选择一个不可达地址时 `initStore()` 返回 `err`,且 `connection`/`device`/`config`/`status` 四个字段全部保持 `null`(`doInit` 的全有或全无语义)。 +5. **重连** — `resetStore()` 后重新发现并连接成功。固件 `main()` 在 EOF 后重入 `run_session`,wasm 侧会先发 `0x00` 冲刷;此项落地时优先验证,若不稳定则退化为同一连接内的回读比对。 + +#### `store.test.ts` — 数据读取与写入 + +6. **G1 · 键位读取** — `config.keymap` 形状为 `[2][4][12]`;layer 0 的七个抽查位与 §5.5 相符;layer 1 全部 `Transparent`。**这是验证扁平数组展开顺序的关键用例**:上游若改变层/行/列主序,抽查位会取到错误的键。 +7. **G1 · 编码器读取** — `config.encoders` 形状 `[2][2]`,四组值与 §5.5 相符。验证 `fetchEncoders` 的双层循环下标顺序。 +8. **G1 · 宏分块** — `config.macros.length === 256` 且初始全零。验证 `fetchMacros` 的分块遍历能走完、能终止、末尾截断正确。 +9. **G1 · 集合读取** — combos / morses / forks 长度均为 8;fork slot 0 与 §5.5 的预置值相符,其余为默认值。 +10. **键位写入往返** — `setKey` 改一个键 → 重新读取一致 → 恢复;`setKeymap` 整表写入 → 重新 `initStore` → 逐项与写入值相等。验证 `keymap.flat().flat()` 与 `fetchKeymap` 严格互逆。**本方案价值最高的单条断言。** +11. **前置校验** — 越界(`setKey` 的 layer/row/col、`setCombo(-1)`、`setMacro` offset 越界)与形状不符(`setKeymap` 层/行/列数)返回 `{type:'invalid'}`,cause 字符串精确匹配,**且不向设备发出任何请求**。 +12. **乐观更新与回滚** — 成功时本地与设备一致;失败时本地复原并返回 `err`。含 `setMacro` 的部分回滚(offset 5 写 3 字节,失败后 5..7 复原、4 与 8 不受影响)。 +13. **乐观更新时序** — `push()` 执行在 `enqueue` 的回调中(微任务),非同步:调用后立即读到旧值,一个 tick 后读到新值。 +14. **串行化** — 连续发起 5 个 `setKey` 不 await,全部成功且顺序正确;其中一个失败不破坏后续链。 +15. **G1 · 错误映射** — 触发一次真实的设备拒绝(如越界的 `set_encoder`),断言 `toKeyboardError` 得到 `{type:'rynk', code:'Invalid'}`;另对 `link closed`、`name:'Disconnected'` 各一条。**已知缺口**:`RYNK_ERROR_CODES` 用 `satisfies` 约束,只校验可赋值性不校验穷尽性,故上游新增错误码不会被抓住(当前 `Busy` 即已遗漏,落入 `{type:'unknown'}`)。用例断言现状并标注 TODO。 + +#### `lifecycle.test.ts` — 推送与拆除 + +16. **G1 · 推送内容** — 采集约 1.5 s:五种事件均出现;LayerChange 在 0/1 间交替;相邻 WpmUpdate 差值为 7;BatteryStatusChange 不出现。断言只针对同类事件的序列,不断言跨类的全局顺序。上游新增事件变体时 `startTopicLoop` 的 `.exhaustive()` 会抛,本用例捕获。 +17. **推送分发** — 各事件写入的是对应的 status 字段(`LayerChange`→`currentLayer`、`WpmUpdate`→`wpm` 等)。 +18. **`topicsReady` 门控** — init 期间 `#status` 仍为 null,此时到达的 topic 不得解引用它,全程无异常。 +19. **拆除顺序** — 在 `next_topic()` 挂起时调用 `resetStore()`,不抛异常,四个 `$state` 字段全部清空。顺序错误时 wasm-bindgen 会抛 use-after-free。 +20. **幂等性** — `resetStore()` 重复调用、未连接时调用均不抛异常。 + +#### `locked.test.ts` — 锁定固件 + +21. **锁定状态报告** — `get_lock_status()` 返回 `locked: true`、`remaining_keys: 2`、解锁键位为 `(0,0)` 与 `(0,11)`。 +22. **锁门拒绝** — 受锁保护的调用(如 `get_matrix_state()`)被拒绝,`toKeyboardError` 得到 `{type:'rynk', code:'Locked'}`。 +23. **解锁流程武装** — `unlock_poll()` 使状态进入 `unlocking: true`。测试无法驱动物理矩阵,因此 `remaining_keys` 永远到不了 0;断言的是解锁流程被正确武装,这正是解锁 UI 的轮询所依赖的。 +24. **写门与锁门分离** — 因固件 `write_requires_unlock = false`,锁定状态下 `set_key` 仍应成功。此项确保 UI 的只读门控依据 `write_requires_unlock` 而非 `locked`。 +25. **已知问题:锁定键盘无法连接** — `fetchStatus` 无条件调用受锁保护的 `get_matrix_state()`,而 `doInit` 在其失败时走全有或全无的回滚路径,因此**锁定状态的键盘当前完全连不上**;`App.svelte:13` 又未检查 `initStore` 的返回值,界面会静默渲染 0 个键。本用例断言这一现状并标注 TODO,待锁定 UI 落地时一并修复。 + +#### `keyboard-ui.test.ts` — 渲染与交互 + +26. **G1 · 渲染数量与标识** — 渲染出 42 个 key div;所有 `row,col` 互不重复(`{#each}` 的 keyed identity)。上游改变 `LayoutInfo` 的 variants/keys 结构时红。 +27. **逐键几何** — 左区键 `transform: rotate(10deg)`、右区键 `rotate(-10deg)`;`(2,5)` 与 `(2,6)` 的 `height` 为 96px,其余为 64px;`width` 与各自 `rect.w × 64` 相符。 +28. **AABB 归一化** — 所有 key 的 `left`/`top` 解析后均 `>= 0`(`bounds.minX`/`minY` 可为负,减错方向会产生负偏移)。 +29. **选中交互** — 点击某键使其获得 `bg-primary`、其余为 `bg-base-300`;点击另一键时选中转移;点击容器空白处全部取消(验证 key 的 `stopPropagation` 与容器 handler 的配合)。事件以 `new Event('pointerdown', { bubbles: true })` 派发即可——handler 仅使用 `stopPropagation`,不依赖 jsdom 的 PointerEvent 支持。断言前 `flushSync()`。 +30. **未连接态** — 渲染 0 个键,容器 `width:0px;height:0px`。 + +#### `smoke.test.ts` — 构建产物 + +31. **构建产物可加载** — 将 `pnpm build:web` 产出的 `dist/index.html` 与其入口 bundle 载入 jsdom,确认应用能挂载、无未捕获异常。用于兜住"开发态正常、打包后因 import 分析或 wasm 资源路径而挂掉"这一类问题。`dist/` 不存在时跳过;CI 中排在 `pnpm build:web` 之后。 + +## 6. 工程集成 + +### 6.1 npm scripts + +```text +"test": "vitest run", +"test:watch": "vitest" +``` + +### 6.2 CI + +新增一个 Linux-only job,与现有 6-OS 构建矩阵并行(不进入关键路径,墙钟时间不变):安装 `qemu-system-misc`、`riscv32imac-unknown-none-elf` target、`wasm-pack`,执行 `pnpm build:wasm` 与 `pnpm build:web` 后 `pnpm test`。 + +**须一并处理的依赖一致性问题**:`.cargo/config.toml` 为 gitignore 的本地文件,因此 CI 中固件从 `git+rmk.git?branch=main` 解析,而 `scripts/build-rynk-wasm.py` 会**另外**再 clone 一次 `main`。两次独立拉取同一移动分支,若 `main` 在其间前进,固件与 wasm 客户端将来自不同的协议 commit,测试会以难以定位的线路错误失败。 + +处理方式:CI 中只 clone 一次并以 `RMK_REPO` 指向它,同时打印其 `rev-parse HEAD` 便于定位。`qemu/run.mjs` 采用与 `build-rynk-wasm.py` 相同的解析顺序(`RMK_REPO` → 同级 `../rmk`),解析到则通过 `cargo --config` 把 `[patch]` 指向该 checkout。本地开发同样受益:有同级 `../rmk` 时固件与 wasm 客户端自动同源。 + +顺带纳入(各一行,成本可忽略):现有 `ci` job 的 ubuntu 分支增加 `pnpm check`(svelte-check 当前为 105 files / 0 errors);`eslint.config.mjs` 的 `'qemu/**'` 忽略项收窄为 `'qemu/src/**'` + `'qemu/target/**'`,使 `qemu/run.mjs` 与新增 harness 脚本纳入 lint。 + +## 7. 风险与对策 + +| 风险 | 影响 | 对策 | +|---|---|---| +| §5.2 的两条编译配置静默失效 | `$state` 不响应,测试行为无法解释 | 在写任何用例之前先验证二者生效 | +| 断言绑定固件夹具,夹具调整时需同步改测试 | 维护成本 | 常量全部集中在 `tests/fixture.ts` 单一文件;夹具与断言基准本就应当一起变更 | +| QEMU 单客户端限制导致并发连接失败 | 测试随机失败 | `fileParallelism: false`;`afterEach` 中确保 `resetStore()` 释放连接 | +| CI 中固件与 wasm 客户端协议版本不一致 | 难以定位的线路错误 | §6.2 的单次 clone + `[patch]` 方案 | +| 固件重连行为不确定 | 用例 5、10 依赖 QEMU 接受第二次连接 | 落地时优先验证;若不稳定,退化为同一连接内以 `read_all_keymap` 回读比对 | +| 首次运行需构建两份 riscv 固件 | 冷启动约 1–2 分钟 | `hookTimeout: 60_000`;两份使用独立 target 目录以保持增量;`RMK_E2E_ADDR` 提供手动模式绕过 | +| `src-tauri` 的 375 行 Rust transport 无覆盖 | 串口/BLE 发现、MTU 分块、session 生命周期无防护 | 本方案不覆盖,属已知缺口 | diff --git a/package.json b/package.json index e667845..9fe7274 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "build:tauri": "tauri build", "build:wasm": "python3 scripts/build-rynk-wasm.py", "qemu": "node qemu/run.mjs", + "test": "vitest run", + "test:qemu": "vitest run --config vitest.qemu.config.ts", "lint": "run-s lint:ts lint:rs", "lint:ts": "eslint .", "lint:rs": "cd src-tauri && cargo clippy --all-targets -- -D warnings", @@ -41,6 +43,7 @@ "svelte-check": "^4.3.1", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "vite": "^8.1.5" + "vite": "^8.1.5", + "vitest": "^4.1.10" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6740733..a5322c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: ^9.2.0 - version: 9.2.0(@typescript-eslint/typescript-estree@8.65.0(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(typescript@6.0.3) + version: 9.2.0(@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) '@iconify-json/lucide': specifier: ^1.2.119 version: 1.2.120 @@ -75,6 +75,9 @@ importers: vite: specifier: ^8.1.5 version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -605,6 +608,9 @@ packages: resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@5.10.0': resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -809,9 +815,15 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -925,6 +937,35 @@ packages: vitest: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vue/compiler-core@3.5.40': resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} @@ -1206,6 +1247,10 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1245,6 +1290,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1292,6 +1341,9 @@ packages: resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} engines: {node: '>=12'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} @@ -1367,6 +1419,9 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1618,10 +1673,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} @@ -2300,6 +2362,9 @@ packages: resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -2316,6 +2381,12 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2377,6 +2448,9 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -2385,6 +2459,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-valid-identifier@1.0.0: resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} engines: {node: '>=20'} @@ -2521,6 +2599,47 @@ packages: vite: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vue-eslint-parser@10.4.1: resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2540,6 +2659,11 @@ packages: engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2573,7 +2697,7 @@ packages: snapshots: - '@antfu/eslint-config@9.2.0(@typescript-eslint/typescript-estree@8.65.0(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(typescript@6.0.3)': + '@antfu/eslint-config@9.2.0(@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.7.0 @@ -2583,7 +2707,7 @@ snapshots: '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.24(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@vitest/eslint-plugin': 1.6.24(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) ansis: 4.3.1 cac: 7.0.0 eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) @@ -2591,13 +2715,13 @@ snapshots: eslint-flat-config-utils: 3.2.0 eslint-merge-processors: 2.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-antfu: 3.2.3(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-command: 3.5.3(@typescript-eslint/typescript-estree@8.65.0(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-command: 3.5.3(@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-import-lite: 0.6.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-jsdoc: 63.3.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-jsonc: 3.3.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-n: 18.2.2(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) eslint-plugin-no-only-tests: 3.4.0 - eslint-plugin-perfectionist: 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-perfectionist: 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) eslint-plugin-pnpm: 1.7.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-regexp: 3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-toml: 1.5.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) @@ -2897,9 +3021,9 @@ snapshots: '@eslint/plugin-kit': 0.7.2 github-slugger: 2.0.0 mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-frontmatter: 2.0.1(supports-color@7.2.0) - mdast-util-gfm: 3.1.0(supports-color@7.2.0) - mdast-util-math: 3.0.0(supports-color@7.2.0) + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 micromark-extension-frontmatter: 2.0.0 micromark-extension-gfm: 3.0.0 micromark-extension-math: 3.1.0 @@ -3048,6 +3172,8 @@ snapshots: '@sindresorhus/base62@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) @@ -3201,10 +3327,17 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -3239,7 +3372,7 @@ snapshots: '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 @@ -3283,7 +3416,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) debug: 4.4.3(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -3308,7 +3441,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.65.0 @@ -3328,17 +3461,59 @@ snapshots: dependencies: valibot: 1.4.2(typescript@6.0.3) - '@vitest/eslint-plugin@1.6.24(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@vitest/eslint-plugin@1.6.24(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) typescript: 6.0.3 + vitest: 4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@vue/compiler-core@3.5.40': dependencies: '@babel/parser': 7.29.7 @@ -3961,6 +4136,8 @@ snapshots: aria-query@5.3.1: {} + assertion-error@2.0.1: {} + axobject-query@4.1.0: {} balanced-match@4.0.4: {} @@ -3989,6 +4166,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4022,6 +4201,8 @@ snapshots: convert-hrtime@5.0.0: {} + convert-source-map@2.0.0: {} + core-js-compat@3.49.0: dependencies: browserslist: 4.28.7 @@ -4077,6 +4258,8 @@ snapshots: entities@7.0.1: {} + es-module-lexer@2.3.1: {} + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -4159,11 +4342,11 @@ snapshots: - '@eslint/css' - typescript - eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.65.0(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: '@es-joy/jsdoccomment': 0.88.0 '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) eslint-plugin-es-x@7.8.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): @@ -4228,9 +4411,9 @@ snapshots: eslint-plugin-no-only-tests@3.4.0: {} - eslint-plugin-perfectionist@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + eslint-plugin-perfectionist@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) natural-orderby: 5.0.0 transitivePeerDependencies: @@ -4435,8 +4618,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + exsolve@1.1.1: {} fast-deep-equal@3.1.3: {} @@ -4687,7 +4876,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-frontmatter@2.0.1(supports-color@7.2.0): + mdast-util-frontmatter@2.0.1: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 @@ -4706,7 +4895,7 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 @@ -4716,7 +4905,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) @@ -4724,7 +4913,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 @@ -4734,7 +4923,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 @@ -4743,19 +4932,19 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-math@3.0.0(supports-color@7.2.0): + mdast-util-math@3.0.0: dependencies: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 @@ -5222,6 +5411,8 @@ snapshots: shell-quote@1.10.0: {} + siginfo@2.0.0: {} + sisteransi@1.0.5: {} source-map-js@1.2.1: {} @@ -5235,6 +5426,10 @@ snapshots: spdx-license-ids@3.0.23: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + strip-bom@3.0.0: {} strip-indent@4.1.1: {} @@ -5309,6 +5504,8 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tinybench@2.9.0: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -5316,6 +5513,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + to-valid-identifier@1.0.0: dependencies: '@sindresorhus/base62': 1.0.0 @@ -5418,6 +5617,33 @@ snapshots: optionalDependencies: vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -5440,6 +5666,11 @@ snapshots: dependencies: isexe: 4.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} xml-name-validator@5.0.0: {} diff --git a/qemu/run.mjs b/qemu/run.mjs index cf067d4..3a174ec 100644 --- a/qemu/run.mjs +++ b/qemu/run.mjs @@ -2,14 +2,38 @@ /* eslint-disable node/prefer-global/process */ import { spawn } from 'node:child_process' -import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const dir = dirname(fileURLToPath(import.meta.url)) const elf = join(dir, 'target/riscv32imac-unknown-none-elf/release/rmk-qemu-riscv') +// smoke.test.ts allocates a free port so a stray listener cannot be dialled by +// mistake, and two checkouts can run their fixtures side by side. +const port = process.env.RMK_QEMU_PORT ?? '7965' + +const RMK_GIT = 'https://github.com/rmk-rs/rmk.git' + +/// Same resolution order as scripts/build-rynk-wasm.py. The firmware and the +/// wasm client speak one protocol, so they must come from one rmk revision — +/// letting cargo resolve `branch = "main"` on its own can drift them apart. +function rmkRepo() { + const env = process.env.RMK_REPO + if (env) return resolve(env) + const sibling = resolve(dir, '../../rmk') + return existsSync(join(sibling, 'rynk/rynk-wasm/Cargo.toml')) ? sibling : null +} + +const repo = rmkRepo() +if (repo) console.log(`patching rmk to ${repo}`) +// JSON.stringify escapes the path into a TOML basic string. +const patch = (repo ? ['rmk', 'rmk-config'] : []).flatMap(crate => [ + '--config', + `patch.${JSON.stringify(RMK_GIT)}.${crate}.path=${JSON.stringify(join(repo, crate))}`, +]) // Extra args go to cargo, e.g. `pnpm qemu --features locked`. -const cargoArgs = ['build', '--release', ...process.argv.slice(2)] +const cargoArgs = ['build', '--release', ...patch, ...process.argv.slice(2)] await new Promise((r, f) => spawn('cargo', cargoArgs, { cwd: dir, stdio: 'inherit' }) @@ -28,7 +52,7 @@ const q = spawn('qemu-system-riscv32', [ '-kernel', elf, '-serial', - 'tcp::7965,server,nowait', + `tcp::${port},server,nowait`, ], { stdio: 'inherit' }) .on('exit', c => process.exit(c ?? 0)) diff --git a/qemu/smoke.test.ts b/qemu/smoke.test.ts new file mode 100644 index 0000000..e5802f3 --- /dev/null +++ b/qemu/smoke.test.ts @@ -0,0 +1,173 @@ +import type { ChildProcess } from 'node:child_process' +import type { JsByteLink, RynkClient } from '../src/rynk/core' +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import net from 'node:net' +import process from 'node:process' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { connectClient } from '../src/rynk/core' + +const WASM = readFileSync(new URL('../src/rynk/wasm/rynk_wasm_bg.wasm', import.meta.url)) + +// Mirrors qemu/src/main.rs. +const ROWS = 4 +const COLS = 12 +const LAYERS = 2 +const ENCODERS = 2 + +function socketLink(sock: net.Socket): JsByteLink { + const chunks: Uint8Array[] = [] + let wake: (() => void) | null = null + let closed = false + const signal = () => { + const w = wake + wake = null + w?.() + } + sock.on('data', (d: Buffer) => { chunks.push(new Uint8Array(d)); signal() }) + sock.on('close', () => { closed = true; signal() }) + sock.on('error', () => { closed = true; signal() }) + + return { + label: 'qemu', + async send(frame) { + await new Promise((res, rej) => { + sock.write(frame, e => (e ? rej(e) : res())) + }) + }, + async recv() { + while (!chunks.length && !closed) await new Promise((res) => { wake = res }) + return chunks.shift() ?? new Uint8Array(0) + }, + async close() { sock.destroy() }, + } +} + +/// Let the OS pick the port, then hand it to qemu. A fixed one lets an +/// unrelated listener answer dial(), or makes the spawn fail outright. +async function freePort(): Promise { + const srv = net.createServer() + await new Promise((res, rej) => { + srv.once('error', rej) + srv.listen(0, '127.0.0.1', res) + }) + const { port } = srv.address() as net.AddressInfo + await new Promise((res) => { srv.close(() => res()) }) + return port +} + +/// Printed by qemu/src/main.rs once its UART is initialised. Until then the +/// fixture resets the 16550 FIFO, discarding anything the host already sent — +/// and qemu accepts the connection during machine init, well before that. +const UART_READY = '[RMK] uart ready' + +async function waitForLine(log: string[], needle: string, child: ChildProcess, deadlineMs: number) { + const start = Date.now() + while (!log.join('').includes(needle)) { + if (child.exitCode !== null) throw new Error(`qemu exited ${child.exitCode} before ${needle}`) + if (Date.now() - start > deadlineMs) throw new Error(`timed out waiting for ${needle}`) + await new Promise((r) => { setTimeout(r, 100) }) + } +} + +async function dial(port: number, deadlineMs: number): Promise { + const start = Date.now() + for (;;) { + try { + return await new Promise((res, rej) => { + const s = net.createConnection({ host: '127.0.0.1', port }) + s.once('connect', () => res(s)) + s.once('error', rej) + }) + } + catch (e) { + if (Date.now() - start > deadlineMs) throw e + await new Promise((r) => { setTimeout(r, 200) }) + } + } +} + +let qemu: ChildProcess +let sock: net.Socket +let client: RynkClient + +beforeAll(async () => { + const port = await freePort() + qemu = spawn('node', ['run.mjs'], { + cwd: new URL('.', import.meta.url).pathname, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, RMK_QEMU_PORT: String(port) }, + }) + // Held rather than inherited: cargo's progress would drown the reporter, but + // a failed handshake is undebuggable without the build and semihosting output. + const log: string[] = [] + qemu.stdout?.on('data', (d: Buffer) => log.push(d.toString())) + qemu.stderr?.on('data', (d: Buffer) => log.push(d.toString())) + try { + // Generous: this also covers a cold cargo build of the riscv firmware. + await waitForLine(log, UART_READY, qemu, 600_000) + sock = await dial(port, 30_000) + client = (await connectClient(socketLink(sock), WASM)).client + } + catch (e) { + console.error(`--- qemu on port ${port} ---\n${log.join('')}`) + throw e + } +}) + +afterAll(() => { + client?.free() + sock?.destroy() + qemu?.kill('SIGTERM') +}) + +it('reports the fixture geometry', async () => { + const caps = await client.get_capabilities() + expect(caps.num_layers).toBe(LAYERS) + expect(caps.num_rows).toBe(ROWS) + expect(caps.num_cols).toBe(COLS) + expect(caps.num_encoders).toBe(ENCODERS) +}) + +it('reads the whole keymap in one paged transfer', async () => { + const flat = await client.read_all_keymap() + expect(flat).toHaveLength(LAYERS * ROWS * COLS) +}) + +it('round-trips a key write', async () => { + const original = await client.get_key(0, 0, 0) + expect(original).not.toBe('Transparent') + try { + await client.set_key(0, 0, 0, 'Transparent') + expect(await client.get_key(0, 0, 0)).toBe('Transparent') + } + finally { + await client.set_key(0, 0, 0, original) + } + expect(await client.get_key(0, 0, 0)).toEqual(original) +}) + +it('round-trips an encoder write', async () => { + const original = await client.get_encoder(0, 0) + const swapped = { clockwise: original.counter_clockwise, counter_clockwise: original.clockwise } + try { + await client.set_encoder(0, 0, swapped) + expect(await client.get_encoder(0, 0)).toEqual(swapped) + } + finally { + await client.set_encoder(0, 0, original) + } +}) + +it('pushes topic events', async () => { + // The fixture's test_topics task publishes layer changes every 200ms. + const event = await client.next_topic() + expect(Object.keys(event)).toHaveLength(1) +}) + +it('exposes the lock gate as unlocked', async () => { + // The default fixture build is `insecure`; --features locked flips this. + expect((await client.get_lock_status()).locked).toBe(false) +}) + +process.on('exit', () => qemu?.kill('SIGKILL')) diff --git a/qemu/src/main.rs b/qemu/src/main.rs index ee5366e..1b35898 100644 --- a/qemu/src/main.rs +++ b/qemu/src/main.rs @@ -158,6 +158,10 @@ async fn main(spawner: Spawner) { let mut rx = Uart::new(); let mut tx = Uart::new(); + // qemu binds the serial socket during machine init, so a host can connect + // and send before this point — and Uart::new() resets the 16550 FIFO, which + // discards whatever it queued. smoke.test.ts waits for this line. + println!("[RMK] uart ready"); let mut keymap_data = KeymapData::new_with_encoder(get_default_keymap(), DEFAULT_ENCODER_MAP); let mut behavior_config = BehaviorConfig::default(); diff --git a/src/App.svelte b/src/App.svelte index 547969f..d540336 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,24 +4,65 @@ import ToolsBar from './components/ToolsBar.svelte' import PageHost from './lib/PageHost.svelte' import Toaster from './lib/Toaster.svelte' - import { discover } from './rynk' - import { keyboardStore } from './stores' + import { canDiscover, connectWebSerial, discover } from './rynk' + import { describeKeyboardError, keyboardStore } from './stores' + + let picking = $state(false) + let pickError = $state(null) $effect(() => { + if (!canDiscover()) return void (async () => { const devices = await discover() - if (!devices.length) return - const connected = await devices[0]!.connect() - await keyboardStore.initStore(connected) - console.warn('init', keyboardStore) + if (devices.length) await keyboardStore.initStore(await devices[0]!.connect()) })() }) + + // Must run from a click: the browser port picker requires a user gesture. + async function pickWebSerial() { + picking = true + pickError = null + try { + // initStore returns a ResultAsync: a failed handshake is an Err, not a throw. + const result = await keyboardStore.initStore(await connectWebSerial()) + if (result.isErr()) pickError = describeKeyboardError(result.error) + } + catch (e) { + // NotFoundError is the user dismissing the picker; a port that refuses to + // open throws NetworkError or InvalidStateError and must be shown. + if (e instanceof DOMException && e.name === 'NotFoundError') return + pickError = e instanceof Error ? e.message : String(e) + } + finally { + picking = false + } + }
- + {#if !canDiscover() && keyboardStore.connection?.phase !== 'connected'} +
+ + {#if pickError} +

{pickError}

+ {/if} +
+ {:else} + + {/if}
diff --git a/src/components/StateBar.svelte b/src/components/StateBar.svelte index b1c349a..c927f43 100644 --- a/src/components/StateBar.svelte +++ b/src/components/StateBar.svelte @@ -1,10 +1,26 @@ - +
- nihao + {text}
diff --git a/src/rynk/core.test.ts b/src/rynk/core.test.ts new file mode 100644 index 0000000..e11add2 --- /dev/null +++ b/src/rynk/core.test.ts @@ -0,0 +1,95 @@ +import type { JsByteLink } from './core' +import { describe, expect, it } from 'vitest' +import { cobsDecode, cobsEncode, probeVersion } from './core' + +/// Round-trip helper: cobsEncode emits a trailing delimiter, cobsDecode wants it gone. +function roundTrip(bytes: number[]): number[] { + const encoded = cobsEncode(new Uint8Array(bytes)) + expect(encoded[encoded.length - 1]).toBe(0) + return [...cobsDecode(encoded.subarray(0, encoded.length - 1))] +} + +describe('cobs', () => { + it('round-trips payloads with and without zeros', () => { + const cases: number[][] = [ + [], + [0], + [0, 0], + [1, 2, 3], + [1, 0, 2, 0, 3], + [0, 1, 0], + Array.from({ length: 300 }, (_, i) => i % 256), + ] + for (const c of cases) expect(roundTrip(c)).toEqual(c) + }) + + it('never emits a zero inside the encoded frame', () => { + const encoded = cobsEncode(new Uint8Array([0, 5, 0, 0, 7])) + expect([...encoded.subarray(0, encoded.length - 1)]).not.toContain(0) + }) + + it('handles a run longer than 254 bytes', () => { + // 0xFF codes mean "254 data bytes, no implicit zero" — the branch that + // silently corrupts payloads if the `code < 0xFF` guard is wrong. + const long: number[] = Array.from({ length: 600 }).fill(0x41) as number[] + expect(roundTrip(long)).toEqual(long) + }) +}) + +/// Reply frame: cmd LE, seq, status, then [major, minor]. +function versionReply(cmd: number, major: number, minor: number): Uint8Array { + return cobsEncode(new Uint8Array([cmd & 0xFF, cmd >> 8, 1, 0, major, minor])) +} + +function fakeLink(chunks: Uint8Array[]): JsByteLink & { sent: Uint8Array[] } { + const queue = [...chunks] + return { + sent: [], + label: 'fake', + async send(frame) { this.sent.push(frame) }, + async recv() { return queue.shift() ?? new Uint8Array(0) }, + async close() {}, + } +} + +describe('probeVersion', () => { + it('reads the version out of a single reply', async () => { + const link = fakeLink([versionReply(0x0001, 1, 4)]) + expect(await probeVersion(link)).toEqual({ major: 1, minor: 4 }) + expect(link.sent).toHaveLength(1) + }) + + it('reassembles a reply split across chunks', async () => { + const full = versionReply(0x0001, 2, 0) + const link = fakeLink([full.subarray(0, 3), full.subarray(3)]) + expect(await probeVersion(link)).toEqual({ major: 2, minor: 0 }) + }) + + it('skips interleaved topic pushes', async () => { + // Topic pushes carry the CMD high bit and can land before the reply. + const topic = cobsEncode(new Uint8Array([0x10, 0x80, 0, 0, 9, 9])) + const link = fakeLink([topic, versionReply(0x0001, 1, 1)]) + expect(await probeVersion(link)).toEqual({ major: 1, minor: 1 }) + }) + + it('throws when the link closes before a reply', async () => { + await expect(probeVersion(fakeLink([]))).rejects.toThrow('link closed') + }) + + it('gives up on a device that opens the port and never answers', async () => { + // Without the deadline this parks forever and the store never leaves + // `connecting`. + const silent: JsByteLink = { + label: 'silent', + async send() {}, + recv: () => new Promise(() => {}), + async close() {}, + } + await expect(probeVersion(silent, 10)).rejects.toThrow('version probe timed out') + }) + + it('classifies both give-up paths as transport faults', async () => { + // toKeyboardError() keys off the name; anything else lands in `unknown`. + await expect(probeVersion(fakeLink([]))).rejects.toMatchObject({ name: 'TransportError' }) + }) +}) diff --git a/src/rynk/core.ts b/src/rynk/core.ts index 394ebf7..5837b8d 100644 --- a/src/rynk/core.ts +++ b/src/rynk/core.ts @@ -7,8 +7,12 @@ export interface JsByteLink { const GET_VERSION = 0x0001 +/// A device can open the port and then never answer. Without a deadline the +/// probe parks forever and leaves the store stuck in `connecting`. +const PROBE_TIMEOUT_MS = 10_000 + /// Each zero-free run is prefixed by its length + 1; 0x00 delimits frames. -function cobsEncode(data: Uint8Array): Uint8Array { +export function cobsEncode(data: Uint8Array): Uint8Array { const out = [0] let codeIdx = 0 let code = 1 @@ -21,6 +25,12 @@ function cobsEncode(data: Uint8Array): Uint8Array { else { out.push(b) code++ + // A full 0xFF block closes with no implicit zero; decode mirrors this. + if (code === 0xFF) { + out[codeIdx] = code + codeIdx = out.push(0) - 1 + code = 1 + } } } out[codeIdx] = code @@ -29,7 +39,7 @@ function cobsEncode(data: Uint8Array): Uint8Array { } /// `frame` must exclude the trailing delimiter, else a spurious zero is appended. -function cobsDecode(frame: Uint8Array): Uint8Array { +export function cobsDecode(frame: Uint8Array): Uint8Array { const out: number[] = [] let i = 0 while (i < frame.length) { @@ -48,23 +58,43 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array { return c } +/// `toKeyboardError` keys off the name, so a silent device reads as a dead link +/// rather than an unknown fault. +function transportError(message: string): Error { + const e = new Error(message) + e.name = 'TransportError' + return e +} + /// Frame: cmd=0x0001 LE, seq=1, empty payload; reply payload is [status, major, minor]. -async function probeVersion(link: JsByteLink) { +export async function probeVersion(link: JsByteLink, timeoutMs = PROBE_TIMEOUT_MS) { await link.send(cobsEncode(new Uint8Array([GET_VERSION & 0xFF, GET_VERSION >> 8, 1]))) - let rx: Uint8Array = new Uint8Array(0) - for (;;) { - const delim = rx.indexOf(0) - if (delim === -1) { - const chunk = await link.recv() - if (!chunk.length) throw new Error('link closed') - rx = concat(rx, chunk) - continue + let timer: ReturnType | undefined + const deadline = new Promise((_res, rej) => { + timer = setTimeout(() => rej(transportError('version probe timed out')), timeoutMs) + }) + // The race usually resolves first; keep the loser from surfacing as an + // unhandled rejection. + deadline.catch(() => {}) + try { + let rx: Uint8Array = new Uint8Array(0) + for (;;) { + const delim = rx.indexOf(0) + if (delim === -1) { + const chunk = await Promise.race([link.recv(), deadline]) + if (!chunk.length) throw transportError('link closed') + rx = concat(rx, chunk) + continue + } + const frame = cobsDecode(rx.subarray(0, delim)) + rx = rx.subarray(delim + 1) + // Topic pushes can interleave; keep reading until the GetVersion reply lands. + if (frame.length >= 6 && frame[0] === (GET_VERSION & 0xFF) && frame[1] === GET_VERSION >> 8) + return { major: frame[4]!, minor: frame[5]! } } - const frame = cobsDecode(rx.subarray(0, delim)) - rx = rx.subarray(delim + 1) - // Topic pushes can interleave; keep reading until the GetVersion reply lands. - if (frame.length >= 6 && frame[0] === (GET_VERSION & 0xFF) && frame[1] === GET_VERSION >> 8) - return { major: frame[4]!, minor: frame[5]! } + } + finally { + clearTimeout(timer) } } @@ -75,10 +105,12 @@ async function loadCore(major: number) { } } -export async function connectClient(link: JsByteLink) { - const { major, minor } = await probeVersion(link) +/// `wasm` overrides where the module is fetched from. The browser default +/// resolves it next to the JS glue, which Node's fetch cannot do (file: URL). +export async function connectClient(link: JsByteLink, wasm?: BufferSource, timeoutMs?: number) { + const { major, minor } = await probeVersion(link, timeoutMs) const core = await loadCore(major) - await core.default() + await core.default(wasm ? { module_or_path: wasm } : undefined) const client = await core.connect(link) return { client, major, minor } } diff --git a/src/rynk/index.ts b/src/rynk/index.ts index ac7a82f..0815b70 100644 --- a/src/rynk/index.ts +++ b/src/rynk/index.ts @@ -25,8 +25,14 @@ export interface TransportInfo { connect: () => Promise } +/// Web mode cannot enumerate: `navigator.serial.requestPort()` needs a user +/// gesture and opens the browser's own picker, so callers use connectWebSerial. +export function canDiscover(): boolean { + return isTauri() +} + export async function discover(): Promise { - if (!isTauri()) return [] + if (!canDiscover()) return [] await closeAllSessions() const [serials, bles, tcps] = await Promise.all([ discoverSerial().catch(() => []), @@ -52,3 +58,4 @@ export async function discover(): Promise { export { connectClient } from './core' export type { JsByteLink } from './core' export type * from './wasm/rynk_wasm.js' +export { connectWebHid, connectWebSerial } from './web' diff --git a/src/stores/index.ts b/src/stores/index.ts index adb7db4..de32905 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,4 +1,4 @@ -export { keyboardStore, toKeyboardError } from './keyboard' +export { describeKeyboardError, keyboardStore, toKeyboardError } from './keyboard' export type { KeyboardError } from './keyboard' export type { ConnectionPhase, diff --git a/src/stores/keyboard/errors.test.ts b/src/stores/keyboard/errors.test.ts new file mode 100644 index 0000000..3e6f904 --- /dev/null +++ b/src/stores/keyboard/errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { describeKeyboardError, RYNK_ERROR_CODES, toKeyboardError } from './errors' + +function named(name: string, message: string): Error { + const e = new Error(message) + e.name = name + return e +} + +describe('toKeyboardError', () => { + it('maps every rynk rejection code, including Busy', () => { + // Read off the source of truth: a new upstream variant lands here, not in a + // literal that silently goes stale. + const codes = Object.keys(RYNK_ERROR_CODES) + expect(codes).toContain('Busy') + for (const code of codes) { + expect(toKeyboardError(named('Rejected', `device rejected ${code}`))) + .toEqual({ type: 'rynk', code }) + } + }) + + it('maps a bare code in the message', () => { + expect(toKeyboardError(new Error('Locked'))).toEqual({ type: 'rynk', code: 'Locked' }) + }) + + it('maps link death to transport', () => { + expect(toKeyboardError(new Error('link closed')).type).toBe('transport') + expect(toKeyboardError(named('Disconnected', 'gone')).type).toBe('transport') + expect(toKeyboardError(named('TransportError', 'io')).type).toBe('transport') + }) + + it('falls back to unknown', () => { + expect(toKeyboardError(named('Rejected', 'device rejected Nonsense')).type).toBe('unknown') + expect(toKeyboardError('a string').type).toBe('unknown') + expect(toKeyboardError(new Error('something else')).type).toBe('unknown') + }) +}) + +describe('describeKeyboardError', () => { + it('names the rejection code', () => { + expect(describeKeyboardError({ type: 'rynk', code: 'Locked' })).toBe('device rejected Locked') + }) + + it('describes the non-rynk variants', () => { + expect(describeKeyboardError({ type: 'transport', cause: new Error('x') })).toBe('link lost') + expect(describeKeyboardError({ type: 'invalid', cause: 'not connected' })).toBe('not connected') + expect(describeKeyboardError({ type: 'unknown', cause: new Error('boom') })).toBe('boom') + expect(describeKeyboardError({ type: 'unknown', cause: 42 })).toBe('unknown error') + }) +}) diff --git a/src/stores/keyboard/errors.ts b/src/stores/keyboard/errors.ts index ce0313f..d985d7d 100644 --- a/src/stores/keyboard/errors.ts +++ b/src/stores/keyboard/errors.ts @@ -1,4 +1,5 @@ import type { RynkError } from '../../rynk' +import { match } from 'ts-pattern' export type KeyboardError = | { type: 'rynk', code: RynkError } @@ -6,22 +7,27 @@ export type KeyboardError | { type: 'invalid', cause: string } | { type: 'unknown', cause: unknown } -const RYNK_ERROR_CODES = [ - 'Malformed', - 'NotReady', - 'StorageFault', - 'Internal', - 'Unimplemented', - 'Invalid', - 'UnknownCmd', - 'Locked', -] as const satisfies readonly RynkError[] +// A Record (not an array) so a new RynkError variant upstream fails this build. +// Exported so tests iterate this list instead of keeping a stale copy. +export const RYNK_ERROR_CODES: Record = { + Busy: true, + Internal: true, + Invalid: true, + Locked: true, + Malformed: true, + NotReady: true, + StorageFault: true, + Unimplemented: true, + UnknownCmd: true, +} const REJECTED_RE = /^device rejected (\w+)$/ const TRANSPORT_NAMES: readonly string[] = ['Disconnected', 'TransportError'] +const RYNK_ERROR_NAMES = new Set(Object.keys(RYNK_ERROR_CODES)) + function isRynkError(s: string): s is RynkError { - return (RYNK_ERROR_CODES as readonly string[]).includes(s) + return RYNK_ERROR_NAMES.has(s) } export function toKeyboardError(e: unknown): KeyboardError { @@ -38,3 +44,13 @@ export function toKeyboardError(e: unknown): KeyboardError { } return { type: 'unknown', cause: e } } + +/// Short, user-facing reason — for the status bar and the connect button. +export function describeKeyboardError(e: KeyboardError): string { + return match(e) + .with({ type: 'rynk' }, x => `device rejected ${x.code}`) + .with({ type: 'transport' }, () => 'link lost') + .with({ type: 'invalid' }, x => x.cause) + .with({ type: 'unknown' }, x => (x.cause instanceof Error ? x.cause.message : 'unknown error')) + .exhaustive() +} diff --git a/src/stores/keyboard/index.ts b/src/stores/keyboard/index.ts index 278ab92..2488212 100644 --- a/src/stores/keyboard/index.ts +++ b/src/stores/keyboard/index.ts @@ -1,4 +1,4 @@ -export { toKeyboardError } from './errors' +export { describeKeyboardError, toKeyboardError } from './errors' export type { KeyboardError } from './errors' export { keyboardStore } from './keyboard.svelte' export type { diff --git a/src/stores/keyboard/keyboard.svelte.ts b/src/stores/keyboard/keyboard.svelte.ts index 7c8f9ca..191b339 100644 --- a/src/stores/keyboard/keyboard.svelte.ts +++ b/src/stores/keyboard/keyboard.svelte.ts @@ -1,16 +1,19 @@ import type { Result } from 'neverthrow' import type { BehaviorConfig, + BleStatus, Combo, ConnectedDevice, DeviceCapabilities, EncoderAction, Fork, KeyAction, + LockStatus, MacroData, Morse, PeripheralStatus, RynkClient, + StorageResetMode, } from '../../rynk' import type { KeyboardError } from './errors' import type { ConnectionState, KeyboardConfig, KeyboardDevice, KeyboardStatus } from './types' @@ -26,6 +29,12 @@ const session = { topicsReady: false, // Held so teardown can await the parked next_topic() before freeing the client. topicLoop: null as Promise | null, + // Set at init so enqueue() can report a dead link without reaching into the store. + onDeath: null as ((cause: KeyboardError) => void) | null, +} + +function notConnected(): ResultAsync { + return errAsync({ type: 'invalid', cause: 'not connected' }) } function enqueue( @@ -37,6 +46,12 @@ function enqueue( (r: Result) => r, (e: unknown) => err(toKeyboardError(e)), ) + .then((r: Result) => { + // A dead link never recovers, so drop the session rather than leave a + // connected-looking store the user can keep editing. + if (r.isErr() && r.error.type === 'transport') session.onDeath?.(r.error) + return r + }) session.chain = result.then( () => {}, () => {}, @@ -44,6 +59,16 @@ function enqueue( return new ResultAsync(result) } +/// Commands and reads that need no optimistic update or rollback. +function runCommand(call: (c: RynkClient) => Promise): ResultAsync { + return enqueue(() => { + const client = session.client + // Checked inside the chain, not at enqueue time: the link can die while queued. + if (!client) return notConnected() + return ResultAsync.fromThrowable(() => call(client), toKeyboardError)() + }) +} + interface Mutation { push: () => T call: (c: RynkClient) => Promise @@ -53,7 +78,7 @@ interface Mutation { function runMutation(m: Mutation): ResultAsync { return enqueue(() => { const client = session.client - if (!client) throw new Error('not connected') + if (!client) return notConnected() const snapshot = m.push() return ResultAsync.fromThrowable(() => m.call(client), toKeyboardError)() .orTee(() => m.undo(snapshot)) @@ -111,13 +136,28 @@ async function fetchMacros(client: RynkClient, caps: DeviceCapabilities): Promis return out.slice(0, caps.macro_space_size) } +async function fetchConfig(client: RynkClient, caps: DeviceCapabilities): Promise { + const behavior = await client.get_behavior() + const defaultLayer = await client.get_default_layer() + const keymap = await fetchKeymap(client, caps) + const encoders = await fetchEncoders(client, caps) + const combos = await client.read_all_combos() + const morses = await client.read_all_morses() + const forks = await fetchForks(client, caps) + const macros = await fetchMacros(client, caps) + return { behavior, combos, defaultLayer, encoders, forks, keymap, macros, morses } +} + async function fetchStatus(client: RynkClient, caps: DeviceCapabilities): Promise { + const lockStatus = await client.get_lock_status() const batteryStatus = caps.ble_enabled ? await client.get_battery_status() : 'Unavailable' + const bleStatus = caps.ble_enabled ? await client.get_ble_status() : null const connectionStatus = await client.get_connection_status() + const connectionType = await client.get_connection_type() const currentLayer = await client.get_current_layer() const ledIndicator = await client.get_led_indicator() - const lockStatus = await client.get_lock_status() - const matrixState = await client.get_matrix_state() + // GetMatrixState is unlock-gated upstream; asking while locked fails connect. + const matrixState = lockStatus.locked ? null : await client.get_matrix_state() const sleepState = await client.get_sleep_state() const wpm = await client.get_wpm() const peripheralStatus: PeripheralStatus[] = [] @@ -126,7 +166,9 @@ async function fetchStatus(client: RynkClient, caps: DeviceCapabilities): Promis } return { batteryStatus, + bleStatus, connectionStatus, + connectionType, currentLayer, ledIndicator, lockStatus, @@ -167,7 +209,39 @@ class KeyboardStoreClass { .with({ BatteryStatusChange: P.select() }, (x) => { this.#status!.batteryStatus = x }) .exhaustive() } - } catch { } + } catch (e) { + // next_topic() only rejects when the link dies. A teardown already in + // flight nulls session.client first, so handleDeath no-ops in that case. + await this.handleDeath(client, toKeyboardError(e), true) + } + } + + /// `fromTopicLoop` marks the caller as the topic loop itself, so teardown + /// skips awaiting it — awaiting your own promise deadlocks. + private async handleDeath(client: RynkClient, cause: KeyboardError, fromTopicLoop: boolean): Promise { + if (session.client !== client) return + await this.teardown({ phase: 'error', label: this.#connection?.label ?? '', cause }, fromTopicLoop) + } + + private async teardown(next: ConnectionState | null, fromTopicLoop = false): Promise { + const connected = session.connected + const client = session.client + const topicLoop = fromTopicLoop ? null : session.topicLoop + session.connected = null + session.client = null + session.topicLoop = null + session.chain = Promise.resolve() + session.topicsReady = false + session.onDeath = null + this.#connection = next + this.#device = null + this.#config = null + this.#status = null + // Order matters: a parked next_topic() borrows the client, so free() must + // wait for the link EOF to unwind the topic loop or wasm-bindgen throws. + await connected?.link?.close() + await topicLoop + client?.free() } private async doInit(connected: ConnectedDevice): Promise { @@ -177,46 +251,36 @@ class KeyboardStoreClass { const { client } = await connectClient(connected.link) session.client = client + session.onDeath = (cause) => { + void this.handleDeath(client, cause, false) + } session.topicLoop = this.startTopicLoop(client) const version = await client.get_version() const info = await client.get_device_info() const capabilities = await client.get_capabilities() const layout = await client.get_layout() - const behavior = await client.get_behavior() - const defaultLayer = await client.get_default_layer() - const keymap = await fetchKeymap(client, capabilities) - const encoders = await fetchEncoders(client, capabilities) - const combos = await client.read_all_combos() - const morses = await client.read_all_morses() - const forks = await fetchForks(client, capabilities) - const macros = await fetchMacros(client, capabilities) + const newConfig = await fetchConfig(client, capabilities) const newStatus = await fetchStatus(client, capabilities) - const newConfig: KeyboardConfig = { - behavior, - combos, - defaultLayer, - encoders, - forks, - keymap, - macros, - morses, - } const newDevice: KeyboardDevice = { capabilities, info, version, layout, } - this.#connection = { phase: 'connected', label: connected.label } + // Prefer the name the keyboard reports over the transport label: Web + // Serial only ever offers the constant 'WebSerial', since getInfo() + // exposes no string descriptors. Earlier phases keep the transport label — + // there is no device info before the handshake. + this.#connection = { phase: 'connected', label: info.product_name.trim() || connected.label } this.#device = newDevice this.#config = newConfig this.#status = newStatus session.chain = Promise.resolve() session.topicsReady = true } catch (e) { - await this.resetStore() + await this.teardown({ phase: 'error', label: connected.label, cause: toKeyboardError(e) }) throw e } } @@ -225,24 +289,16 @@ class KeyboardStoreClass { return ResultAsync.fromThrowable(() => this.doInit(connected), toKeyboardError)() } + /// Hard reset: drops the session and leaves no connection state behind. async resetStore(): Promise { - const connected = session.connected - const client = session.client - const topicLoop = session.topicLoop - session.connected = null - session.client = null - session.topicLoop = null - session.chain = Promise.resolve() - session.topicsReady = false - this.#connection = null - this.#device = null - this.#config = null - this.#status = null - // Order matters: a parked next_topic() borrows the client, so free() must - // wait for the link EOF to unwind the topic loop or wasm-bindgen throws. - await connected?.link?.close() - await topicLoop - client?.free() + await this.teardown(null) + } + + /// User-initiated close; keeps the label visible so the UI can offer a reconnect. + async disconnect(): Promise { + await this.teardown( + this.#connection ? { phase: 'disconnected', label: this.#connection.label } : null, + ) } setKey( @@ -451,6 +507,111 @@ class KeyboardStoreClass { undo: (snapshot) => { if (this.#config) this.#config.defaultLayer = snapshot }, }) } + + /// Re-poll everything topic pushes don't cover (matrix, peripherals, lock). + refreshStatus(): ResultAsync { + const caps = this.#device?.capabilities + if (!caps) return notConnected() + return runCommand(async (c) => { + this.#status = await fetchStatus(c, caps) + }) + } + + refreshLockStatus(): ResultAsync { + return runCommand(c => c.get_lock_status()) + .andTee((s) => { if (this.#status) this.#status.lockStatus = s }) + } + + lock(): ResultAsync { + return runCommand(async (c) => { + await c.lock() + const status = await c.get_lock_status() + if (!this.#status) return + this.#status.lockStatus = status + // Matrix state is unlock-gated, so the cached bitmap is now unreadable. + this.#status.matrixState = null + }) + } + + /// One step of the unlock ceremony: the user holds `lockStatus.key_positions` + /// while the host polls. Call until `locked` clears or `unlocking` lapses. + unlockPoll(): ResultAsync { + return runCommand(async (c) => { + const status = await c.unlock_poll() + if (this.#status) { + this.#status.lockStatus = status + if (!status.locked && !this.#status.matrixState) + this.#status.matrixState = await c.get_matrix_state() + } + return status + }) + } + + refreshBleStatus(): ResultAsync { + return runCommand(c => c.get_ble_status()) + .andTee((s) => { if (this.#status) this.#status.bleStatus = s }) + } + + switchBleProfile(slot: number): ResultAsync { + return this.bleProfileCmd(slot, (c, s) => c.switch_ble_profile(s)) + } + + /// Unlock-gated upstream: deleting a bond opens a re-pair hijack window. + clearBleProfile(slot: number): ResultAsync { + return this.bleProfileCmd(slot, (c, s) => c.clear_ble_profile(s)) + } + + private bleProfileCmd( + slot: number, + call: (c: RynkClient, slot: number) => Promise, + ): ResultAsync { + const caps = this.#device?.capabilities + if (!caps) return notConnected() + if (!caps.ble_enabled) return invalid('device has no BLE') + if (slot < 0 || slot >= caps.num_ble_profiles) return invalid(`ble profile ${slot} out of range`) + return runCommand(async (c) => { + await call(c, slot) + if (this.#status) this.#status.bleStatus = await c.get_ble_status() + }) + } + + reboot(): ResultAsync { + return this.endSession(c => c.reboot()) + } + + bootloaderJump(): ResultAsync { + return this.endSession(c => c.bootloader_jump()) + } + + /// Firmware only implements `Full`; `LayoutOnly` comes back `Unimplemented`. + /// The refetch shares this chain slot so nothing reads the wiped config. + storageReset(mode: StorageResetMode): ResultAsync { + const caps = this.#device?.capabilities + if (!caps) return notConnected() + return runCommand(async (c) => { + await c.storage_reset(mode) + this.#config = await fetchConfig(c, caps) + }) + } + + /// Reboot and bootloader jump are fire-and-forget upstream — the device resets + /// before it can reply, so the ack may never land. The session is gone either + /// way, so the teardown runs on both outcomes and is awaited before we settle: + /// a caller that reconnects immediately must not race a half-closed link. + private endSession(call: (c: RynkClient) => Promise): ResultAsync { + const label = this.#connection?.label ?? '' + // Never rejects: a close() fault must not mask the command's own outcome. + const close = () => ResultAsync.fromSafePromise( + this.teardown({ phase: 'disconnected', label }).catch(() => {}), + ) + return runCommand(call) + .andThrough(close) + // `invalid` is the not-connected guard: the command never reached the + // device, so leave whatever phase the death path already recorded. + .orElse(e => (e.type === 'invalid' + ? errAsync(e) + : close().andThen(() => errAsync(e)))) + } } export const keyboardStore = new KeyboardStoreClass() diff --git a/src/stores/keyboard/keyboard.test.ts b/src/stores/keyboard/keyboard.test.ts new file mode 100644 index 0000000..2b00fb0 --- /dev/null +++ b/src/stores/keyboard/keyboard.test.ts @@ -0,0 +1,491 @@ +import type { ConnectedDevice, DeviceCapabilities, KeyAction, RynkClient, StorageResetMode, TopicEvent } from '../../rynk' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const connectClient = vi.hoisted(() => vi.fn()) +vi.mock('../../rynk', () => ({ connectClient })) + +const { keyboardStore } = await import('./keyboard.svelte') + +const CAPS: DeviceCapabilities = { + num_layers: 1, + num_rows: 1, + num_cols: 2, + num_encoders: 0, + max_combos: 0, + max_combo_keys: 4, + macro_space_size: 0, + max_morse: 0, + max_patterns_per_key: 4, + max_forks: 0, + storage_enabled: true, + lighting_enabled: false, + is_split: false, + num_split_peripherals: 0, + ble_enabled: false, + num_ble_profiles: 0, + max_payload_size: 64, + max_bulk_keys: 16, + max_bulk_items: 4, + macro_chunk_size: 28, + bulk_transfer_supported: true, +} + +const BLE_CAPS: DeviceCapabilities = { ...CAPS, ble_enabled: true, num_ble_profiles: 3 } + +function rejection(name: string, message: string): Error { + const e = new Error(message) + e.name = name + return e +} + +/// Stands in for the wasm RynkClient. Only the methods the store actually +/// reaches are implemented; the cast keeps the other ~40 off the fake. +class FakeClient { + caps: DeviceCapabilities = CAPS + /// Deliberately unlike the transport label, so tests show which one the + /// connection state picks up. + productName = 'Fake60' + keymap: KeyAction[] = ['No', 'No'] + locked = false + freed = false + /// Every client call in order — proves the store serializes the chain. + calls: string[] = [] + /// Queued failure for the next set_key, so tests can force a rollback. + failSetKey: Error | null = null + matrixReads = 0 + bleProfile = 0 + /// Queued failure for the next session-ending command (reboot et al). + failEndSession: Error | null = null + private killTopic: ((e: Error) => void) | null = null + + private log(name: string, value: T): Promise { + this.calls.push(name) + return Promise.resolve(value) + } + + get_version() { return this.log('get_version', { major: 1, minor: 0 }) } + get_device_info() { + return this.log('get_device_info', { + rmk_version: { major: 0, minor: 1, patch: 0 }, + vendor_id: 1, + product_id: 2, + manufacturer: 'test', + product_name: this.productName, + serial_number: 'x', + }) + } + + get_capabilities() { return this.log('get_capabilities', this.caps) } + get_layout() { return this.log('get_layout', { default_variant: 0, variants: [] }) } + get_behavior() { return this.log('get_behavior', {}) } + get_default_layer() { return this.log('get_default_layer', 0) } + read_all_keymap() { return this.log('read_all_keymap', [...this.keymap]) } + read_all_combos() { return this.log('read_all_combos', []) } + read_all_morses() { return this.log('read_all_morses', []) } + get_lock_status() { + return this.log('get_lock_status', { + locked: this.locked, + unlocking: false, + remaining_keys: 0, + key_positions: [], + }) + } + + get_battery_status() { return this.log('get_battery_status', 'Unavailable') } + get_ble_status() { return this.log('get_ble_status', { profile: this.bleProfile, state: 'Inactive' }) } + get_connection_status() { + return this.log('get_connection_status', { + usb: 'Configured', + ble: { profile: 0, state: 'Inactive' }, + }) + } + + get_connection_type() { return this.log('get_connection_type', 'Usb') } + get_current_layer() { return this.log('get_current_layer', 0) } + get_led_indicator() { + return this.log('get_led_indicator', { + num_lock: false, + caps_lock: false, + scroll_lock: false, + compose: false, + kana: false, + }) + } + + get_matrix_state() { + this.matrixReads++ + // Mirrors the firmware: GetMatrixState is an unlock-gated command. + if (this.locked) return Promise.reject(rejection('Rejected', 'device rejected Locked')) + return this.log('get_matrix_state', { pressed_bitmap: [0] }) + } + + get_sleep_state() { return this.log('get_sleep_state', false) } + get_wpm() { return this.log('get_wpm', 0) } + + async set_key(layer: number, row: number, col: number, action: KeyAction) { + this.calls.push(`set_key:${layer},${row},${col}`) + // Yield so a second queued call would interleave here if unserialized. + await Promise.resolve() + const fail = this.failSetKey + this.failSetKey = null + if (fail) throw fail + this.keymap[row * CAPS.num_cols + col] = action + } + + async lock() { + this.calls.push('lock') + this.locked = true + } + + /// The real ceremony needs the user holding keys; unlock on the first poll. + async unlock_poll() { + this.calls.push('unlock_poll') + this.locked = false + return { locked: false, unlocking: false, remaining_keys: 0, key_positions: [] } + } + + async switch_ble_profile(slot: number) { + this.calls.push(`switch_ble_profile:${slot}`) + this.bleProfile = slot + } + + async clear_ble_profile(slot: number) { + this.calls.push(`clear_ble_profile:${slot}`) + if (this.bleProfile === slot) this.bleProfile = 0 + } + + async storage_reset(mode: StorageResetMode) { + this.calls.push(`storage_reset:${mode}`) + if (mode === 'LayoutOnly') throw rejection('Rejected', 'device rejected Unimplemented') + this.keymap = ['No', 'No'] + } + + reboot() { return this.endSession('reboot') } + bootloader_jump() { return this.endSession('bootloader_jump') } + + /// The real device resets before it can ack, so tests queue the failure that + /// stands in for that — the store must still drop the session. + private async endSession(name: string) { + this.calls.push(name) + const fail = this.failEndSession + this.failEndSession = null + if (fail) throw fail + } + + next_topic(): Promise { + return new Promise((_resolve, reject) => { + this.killTopic = reject + }) + } + + /// Mirrors real link death: a parked next_topic() rejects on EOF. + die() { this.killTopic?.(rejection('Disconnected', 'link closed')) } + free() { this.freed = true } +} + +function connect(client: FakeClient): ConnectedDevice { + const link = { + label: 'fake', + send: async () => {}, + recv: async () => new Uint8Array(0), + close: async () => { client.die() }, + } + connectClient.mockResolvedValue({ client: client as unknown as RynkClient, major: 1, minor: 0 }) + return { link, descriptor: {}, label: 'fake' } as unknown as ConnectedDevice +} + +async function connected(client = new FakeClient()): Promise { + const result = await keyboardStore.initStore(connect(client)) + expect(result.isOk()).toBe(true) + return client +} + +beforeEach(async () => { + await keyboardStore.resetStore() + connectClient.mockReset() +}) + +describe('connect', () => { + it('populates device, config and status', async () => { + const client = await connected() + expect(keyboardStore.connection).toMatchObject({ phase: 'connected' }) + expect(keyboardStore.device?.capabilities.num_cols).toBe(2) + expect(keyboardStore.config?.keymap).toEqual([[['No', 'No']]]) + expect(keyboardStore.status?.matrixState).toEqual({ pressed_bitmap: [0] }) + expect(client.freed).toBe(false) + }) + + it('labels the session with the reported product name', async () => { + // Web Serial can only offer the constant 'WebSerial' as a transport label, + // so the name the device reports is what the UI has to show. + await connected() + expect(keyboardStore.connection).toEqual({ phase: 'connected', label: 'Fake60' }) + }) + + it('falls back to the transport label when the device reports no name', async () => { + const client = new FakeClient() + client.productName = ' ' + await connected(client) + expect(keyboardStore.connection).toEqual({ phase: 'connected', label: 'fake' }) + }) + + it('keeps the transport label while connecting and on a failed handshake', async () => { + // There is no device info before the handshake lands. connect() arms the + // resolved mock, so the rejection has to be installed after it. + const device = connect(new FakeClient()) + connectClient.mockRejectedValue(rejection('Rejected', 'device rejected Busy')) + expect((await keyboardStore.initStore(device)).isErr()).toBe(true) + expect(keyboardStore.connection).toMatchObject({ phase: 'error', label: 'fake' }) + }) + + it('connects a locked device without reading the gated matrix state', async () => { + const client = new FakeClient() + client.locked = true + await connected(client) + expect(keyboardStore.connection?.phase).toBe('connected') + expect(keyboardStore.status?.lockStatus.locked).toBe(true) + expect(keyboardStore.status?.matrixState).toBeNull() + expect(client.matrixReads).toBe(0) + }) + + it('reports a failed handshake as an error phase', async () => { + connectClient.mockRejectedValue(rejection('Rejected', 'device rejected Busy')) + const result = await keyboardStore.initStore( + { link: { label: 'x', send: async () => {}, recv: async () => new Uint8Array(0), close: async () => {} } } as unknown as ConnectedDevice, + ) + expect(result.isErr()).toBe(true) + expect(keyboardStore.connection).toMatchObject({ + phase: 'error', + cause: { type: 'rynk', code: 'Busy' }, + }) + expect(keyboardStore.config).toBeNull() + }) +}) + +describe('mutations', () => { + it('applies optimistically and keeps the value on success', async () => { + await connected() + const result = await keyboardStore.setKey(0, 0, 1, 'Transparent') + expect(result.isOk()).toBe(true) + expect(keyboardStore.config?.keymap[0]![0]![1]).toBe('Transparent') + }) + + it('rolls back and surfaces the rynk code on rejection', async () => { + const client = await connected() + client.failSetKey = rejection('Rejected', 'device rejected Locked') + const result = await keyboardStore.setKey(0, 0, 1, 'Transparent') + expect(result._unsafeUnwrapErr()).toEqual({ type: 'rynk', code: 'Locked' }) + expect(keyboardStore.config?.keymap[0]![0]![1]).toBe('No') + }) + + it('rejects out-of-range coordinates without touching the device', async () => { + const client = await connected() + const before = client.calls.length + const result = await keyboardStore.setKey(0, 0, 9, 'Transparent') + expect(result._unsafeUnwrapErr().type).toBe('invalid') + expect(client.calls).toHaveLength(before) + }) + + it('serializes concurrent writes instead of interleaving them', async () => { + const client = await connected() + client.calls.length = 0 + await Promise.all([ + keyboardStore.setKey(0, 0, 0, 'Transparent'), + keyboardStore.setKey(0, 0, 1, 'Transparent'), + ]) + expect(client.calls).toEqual(['set_key:0,0,0', 'set_key:0,0,1']) + }) + + it('validates keymap shape before writing', async () => { + await connected() + const result = await keyboardStore.setKeymap([[['No']]]) + expect(result._unsafeUnwrapErr().type).toBe('invalid') + }) +}) + +describe('lock gate', () => { + it('drops the cached matrix state when locking', async () => { + await connected() + expect(keyboardStore.status?.matrixState).not.toBeNull() + const result = await keyboardStore.lock() + expect(result.isOk()).toBe(true) + expect(keyboardStore.status?.lockStatus.locked).toBe(true) + expect(keyboardStore.status?.matrixState).toBeNull() + }) + + it('reads the gated matrix state back once unlocked', async () => { + const client = new FakeClient() + client.locked = true + await connected(client) + expect(keyboardStore.status?.matrixState).toBeNull() + + const result = await keyboardStore.unlockPoll() + expect(result._unsafeUnwrap().locked).toBe(false) + expect(keyboardStore.status?.matrixState).toEqual({ pressed_bitmap: [0] }) + }) +}) + +describe('link death', () => { + it('tears the session down when a request hits a dead link', async () => { + const client = await connected() + client.failSetKey = rejection('Disconnected', 'link closed') + const result = await keyboardStore.setKey(0, 0, 1, 'Transparent') + expect(result._unsafeUnwrapErr().type).toBe('transport') + // The teardown is kicked off from the chain; let it settle. + await vi.waitFor(() => expect(keyboardStore.connection?.phase).toBe('error')) + expect(keyboardStore.config).toBeNull() + expect(keyboardStore.device).toBeNull() + expect(client.freed).toBe(true) + }) + + it('tears the session down when the topic loop sees EOF', async () => { + const client = await connected() + client.die() + await vi.waitFor(() => expect(keyboardStore.connection?.phase).toBe('error')) + expect(keyboardStore.connection?.cause?.type).toBe('transport') + expect(keyboardStore.status).toBeNull() + expect(client.freed).toBe(true) + }) + + it('rejects writes issued after the link died', async () => { + const client = await connected() + client.die() + await vi.waitFor(() => expect(keyboardStore.connection?.phase).toBe('error')) + const result = await keyboardStore.setKey(0, 0, 1, 'Transparent') + expect(result._unsafeUnwrapErr().type).toBe('invalid') + }) +}) + +describe('ble', () => { + async function bleConnected(): Promise { + const client = new FakeClient() + client.caps = BLE_CAPS + return await connected(client) + } + + it('caches the refreshed status', async () => { + const client = await bleConnected() + client.bleProfile = 2 + const result = await keyboardStore.refreshBleStatus() + expect(result._unsafeUnwrap()).toEqual({ profile: 2, state: 'Inactive' }) + expect(keyboardStore.status?.bleStatus).toEqual({ profile: 2, state: 'Inactive' }) + }) + + it('switches a profile and reads the new status back', async () => { + const client = await bleConnected() + client.calls.length = 0 + const result = await keyboardStore.switchBleProfile(1) + expect(result.isOk()).toBe(true) + expect(client.calls).toEqual(['switch_ble_profile:1', 'get_ble_status']) + expect(keyboardStore.status?.bleStatus?.profile).toBe(1) + }) + + it('clears a profile and reads the new status back', async () => { + const client = await bleConnected() + client.bleProfile = 1 + client.calls.length = 0 + const result = await keyboardStore.clearBleProfile(1) + expect(result.isOk()).toBe(true) + expect(client.calls).toEqual(['clear_ble_profile:1', 'get_ble_status']) + expect(keyboardStore.status?.bleStatus?.profile).toBe(0) + }) + + it('rejects an out-of-range slot without touching the device', async () => { + const client = await bleConnected() + const before = client.calls.length + expect((await keyboardStore.switchBleProfile(3))._unsafeUnwrapErr().type).toBe('invalid') + expect(client.calls).toHaveLength(before) + }) + + it('rejects profile commands on a device without BLE', async () => { + // The default fixture reports ble_enabled: false. + await connected() + const result = await keyboardStore.switchBleProfile(0) + expect(result._unsafeUnwrapErr()).toEqual({ type: 'invalid', cause: 'device has no BLE' }) + }) +}) + +describe('storage reset', () => { + it('refetches the wiped config in the same chain slot', async () => { + const client = await connected() + await keyboardStore.setKey(0, 0, 1, 'Transparent') + expect(keyboardStore.config?.keymap[0]![0]![1]).toBe('Transparent') + + const result = await keyboardStore.storageReset('Full') + expect(result.isOk()).toBe(true) + expect(client.calls).toContain('storage_reset:Full') + expect(keyboardStore.config?.keymap[0]![0]![1]).toBe('No') + }) + + it('surfaces Unimplemented and leaves the session alone', async () => { + const client = await connected() + const result = await keyboardStore.storageReset('LayoutOnly') + expect(result._unsafeUnwrapErr()).toEqual({ type: 'rynk', code: 'Unimplemented' }) + expect(keyboardStore.connection?.phase).toBe('connected') + expect(client.freed).toBe(false) + }) +}) + +describe('session-ending commands', () => { + for (const [name, call] of [ + ['reboot', () => keyboardStore.reboot()], + ['bootloader_jump', () => keyboardStore.bootloaderJump()], + ] as const) { + it(`${name} tears the session down before it settles`, async () => { + const client = await connected() + const result = await call() + expect(result.isOk()).toBe(true) + expect(client.calls).toContain(name) + // Awaited, not fire-and-forget: no waitFor here on purpose. + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) + expect(keyboardStore.device).toBeNull() + expect(keyboardStore.config).toBeNull() + expect(client.freed).toBe(true) + }) + + it(`${name} still tears down when the ack never lands`, async () => { + const client = await connected() + // The device resets mid-command: the reply is a rejection, not a dead link. + client.failEndSession = rejection('Rejected', 'device rejected NotReady') + const result = await call() + expect(result._unsafeUnwrapErr()).toEqual({ type: 'rynk', code: 'NotReady' }) + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) + expect(client.freed).toBe(true) + }) + } + + it('reports disconnected, not link lost, when the device drops the link first', async () => { + const client = await connected() + client.failEndSession = rejection('Disconnected', 'link closed') + const result = await keyboardStore.reboot() + expect(result._unsafeUnwrapErr().type).toBe('transport') + // The death path fires too; the user-initiated teardown is the one that sticks. + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) + expect(client.freed).toBe(true) + }) + + it('rejects a reboot after the link died without relabelling the phase', async () => { + const client = await connected() + client.die() + await vi.waitFor(() => expect(keyboardStore.connection?.phase).toBe('error')) + expect((await keyboardStore.reboot())._unsafeUnwrapErr().type).toBe('invalid') + // The command never reached the device, so 'link lost' must survive. + expect(keyboardStore.connection?.phase).toBe('error') + }) +}) + +describe('disconnect', () => { + it('keeps the label and frees the client', async () => { + const client = await connected() + await keyboardStore.disconnect() + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) + expect(keyboardStore.config).toBeNull() + expect(client.freed).toBe(true) + }) + + it('resetStore leaves no connection state', async () => { + await connected() + await keyboardStore.resetStore() + expect(keyboardStore.connection).toBeNull() + }) +}) diff --git a/src/stores/keyboard/types.ts b/src/stores/keyboard/types.ts index 476c53a..d25a22c 100644 --- a/src/stores/keyboard/types.ts +++ b/src/stores/keyboard/types.ts @@ -1,8 +1,10 @@ import type { BatteryStatus, BehaviorConfig, + BleStatus, Combo, ConnectionStatus, + ConnectionType, DeviceCapabilities, DeviceInfo, EncoderAction, @@ -16,12 +18,15 @@ import type { PeripheralStatus, ProtocolVersion, } from '../../rynk' +import type { KeyboardError } from './errors' -export type ConnectionPhase = 'connecting' | 'connected' | 'error' +export type ConnectionPhase = 'connecting' | 'connected' | 'disconnected' | 'error' export interface ConnectionState { phase: ConnectionPhase label: string + /// Set only on `error`; why the session ended. + cause?: KeyboardError } export interface KeyboardDevice { @@ -44,11 +49,15 @@ export interface KeyboardConfig { export interface KeyboardStatus { batteryStatus: BatteryStatus + /// null unless the device reports `ble_enabled`. + bleStatus: BleStatus | null connectionStatus: ConnectionStatus + connectionType: ConnectionType currentLayer: number ledIndicator: LedIndicator lockStatus: LockStatus - matrixState: MatrixState + /// null while locked — GetMatrixState is an unlock-gated command. + matrixState: MatrixState | null peripheralStatus: PeripheralStatus[] sleepState: boolean wpm: number diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6adf2de --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config' + +// qemu/ is excluded: those tests need a riscv toolchain and qemu-system-riscv32. +// Run them with `pnpm test:qemu`. +export default mergeConfig(viteConfig, defineConfig({ + test: { include: ['src/**/*.test.ts'] }, +})) diff --git a/vitest.qemu.config.ts b/vitest.qemu.config.ts new file mode 100644 index 0000000..401a96b --- /dev/null +++ b/vitest.qemu.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig(viteConfig, defineConfig({ + test: { + include: ['qemu/**/*.test.ts'], + // A cold cargo build pulls rmk from git and compiles a riscv firmware. + hookTimeout: 900_000, + testTimeout: 60_000, + // One qemu instance, one TCP port. + fileParallelism: false, + }, +}))