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 @@
## 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,
+ },
+}))