From 1afb3e07cd0dbb6c0fc501cd6cd43e09dcab3105 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 22:28:56 +0800 Subject: [PATCH 01/10] fix(rynk): correct COBS encoding past 254 bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cobsEncode never closed a full 0xFF block, so `code` overflowed 255 and was truncated by Uint8Array — any payload with a zero-free run longer than 254 bytes encoded to garbage. cobsDecode already handled the 0xFF case. Latent today (the only encode site is the 3-byte GetVersion probe), but the helpers are now exported as protocol primitives for tests. connectClient takes an optional wasm source: the browser default resolves the module next to the JS glue, which Node fetch cannot do for a file: URL. --- src/rynk/core.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/rynk/core.ts b/src/rynk/core.ts index 394ebf7..14b707a 100644 --- a/src/rynk/core.ts +++ b/src/rynk/core.ts @@ -8,7 +8,7 @@ export interface JsByteLink { const GET_VERSION = 0x0001 /// 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 +21,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 +35,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) { @@ -49,7 +55,7 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array { } /// 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) { await link.send(cobsEncode(new Uint8Array([GET_VERSION & 0xFF, GET_VERSION >> 8, 1]))) let rx: Uint8Array = new Uint8Array(0) for (;;) { @@ -75,10 +81,12 @@ async function loadCore(major: number) { } } -export async function connectClient(link: JsByteLink) { +/// `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) { const { major, minor } = await probeVersion(link) 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 } } From 185d02debe342b24b8adb5c0366eabc2ad46b957 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 22:29:10 +0800 Subject: [PATCH 02/10] feat(store): detect link death, add lock/BLE/system commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead link used to leave the store looking connected: the topic loop swallowed its exception and nothing called resetStore, so the UI kept an editable keymap until the next write failed. Both death paths — a parked next_topic() rejecting and any request returning a transport error — now converge on teardown, which clears device/config/status and reports { phase: error, cause }. teardown takes fromTopicLoop because awaiting session.topicLoop from inside that loop deadlocks on its own promise. Also fixes connecting to a locked device: GetMatrixState is unlock-gated upstream, and fetchStatus asked for it unconditionally, failing the whole handshake. It is now skipped while locked and read back after unlockPoll. New commands: refreshStatus, refreshLockStatus, lock, unlockPoll, refreshBleStatus, switchBleProfile, clearBleProfile, reboot, bootloaderJump, storageReset. Reboot and bootloader jump are fire-and-forget upstream, so both the ack and a transport failure end the session. KeyboardError missed the Busy variant; RYNK_ERROR_CODES is now a Record so a new upstream variant fails the build. --- src/components/StateBar.svelte | 19 ++- src/stores/keyboard/errors.ts | 26 +-- src/stores/keyboard/keyboard.svelte.ts | 226 ++++++++++++++++++++----- src/stores/keyboard/types.ts | 13 +- 4 files changed, 227 insertions(+), 57 deletions(-) diff --git a/src/components/StateBar.svelte b/src/components/StateBar.svelte index b1c349a..d874bfb 100644 --- a/src/components/StateBar.svelte +++ b/src/components/StateBar.svelte @@ -1,10 +1,21 @@ - +
- nihao + {text}
diff --git a/src/stores/keyboard/errors.ts b/src/stores/keyboard/errors.ts index ce0313f..06661ca 100644 --- a/src/stores/keyboard/errors.ts +++ b/src/stores/keyboard/errors.ts @@ -6,22 +6,26 @@ 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. +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 { diff --git a/src/stores/keyboard/keyboard.svelte.ts b/src/stores/keyboard/keyboard.svelte.ts index 7c8f9ca..6d8ced1 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,32 +251,18 @@ 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, @@ -216,7 +276,7 @@ class KeyboardStoreClass { 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 +285,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 +503,100 @@ 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. Either way the session ends. + private endSession(call: (c: RynkClient) => Promise): ResultAsync { + const label = this.#connection?.label ?? '' + return runCommand(call) + .andTee(() => { void this.teardown({ phase: 'disconnected', label }) }) + } } export const keyboardStore = new KeyboardStoreClass() 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 From 89aff60547235f121a3e36951bb6f911b54f48b2 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 22:29:21 +0800 Subject: [PATCH 03/10] feat(web): wire up the Web Serial connect path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web.ts was dead code: index.ts imported WebByteLink with `import type`, so it never existed at runtime, and discover() returns [] outside Tauri. Web mode could not reach a device at all. connectWebSerial/connectWebHid are now real exports, and canDiscover() names the Tauri-only enumeration so App can offer a click-driven picker instead — the browser requires a user gesture for requestPort(). --- src/App.svelte | 41 +++++++++++++++++++++++++++++++++++------ src/rynk/index.ts | 9 ++++++++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 547969f..480f8c3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,24 +4,53 @@ import ToolsBar from './components/ToolsBar.svelte' import PageHost from './lib/PageHost.svelte' import Toaster from './lib/Toaster.svelte' - import { discover } from './rynk' + import { canDiscover, connectWebSerial, discover } from './rynk' import { keyboardStore } from './stores' + let picking = $state(false) + $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 + try { + await keyboardStore.initStore(await connectWebSerial()) + } + catch { /* user dismissed the picker */ } + finally { + picking = false + } + }
- + {#if !canDiscover() && keyboardStore.connection?.phase !== 'connected'} +
+ +
+ {:else} + + {/if}
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' From 5d332f70dc1394ed4f58a3f6a57a290229e91b81 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 22:29:32 +0800 Subject: [PATCH 04/10] test: add unit suites and a qemu end-to-end smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI only ran lint and build, and the qemu fixture had no consumer. 26 unit tests cover the hand-rolled wire code (COBS round-trips including runs past 254 bytes, chunk reassembly, interleaved topic pushes), the rynk error mapping, and the store: optimistic rollback, request serialization, teardown on link death, connecting while locked, and the unlock ceremony. 6 smoke tests drive the real riscv fixture firmware through the real rynk-wasm client over the UART-to-TCP bridge. They live behind pnpm test:qemu because they need qemu-system-riscv32 and a riscv target, and run as a Linux-only CI job. deploy now waits on it — a protocol regression should not ship. CI also runs pnpm check, which was never wired up. --- .github/workflows/ci.yml | 30 ++- package.json | 5 +- pnpm-lock.yaml | 287 +++++++++++++++++++++--- qemu/smoke.test.ts | 129 +++++++++++ src/rynk/core.test.ts | 78 +++++++ src/stores/keyboard/errors.test.ts | 44 ++++ src/stores/keyboard/keyboard.test.ts | 313 +++++++++++++++++++++++++++ vitest.config.ts | 8 + vitest.qemu.config.ts | 13 ++ 9 files changed, 877 insertions(+), 30 deletions(-) create mode 100644 qemu/smoke.test.ts create mode 100644 src/rynk/core.test.ts create mode 100644 src/stores/keyboard/errors.test.ts create mode 100644 src/stores/keyboard/keyboard.test.ts create mode 100644 vitest.config.ts create mode 100644 vitest.qemu.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c70365..d9580b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,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 +50,34 @@ 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 + - uses: pnpm/action-setup@v6 + with: + version: 11 + - uses: actions/setup-node@v7 + with: + node-version: 24 + + - uses: cargo-bins/cargo-binstall@main + + - 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 + + - 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 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/smoke.test.ts b/qemu/smoke.test.ts new file mode 100644 index 0000000..8a77838 --- /dev/null +++ b/qemu/smoke.test.ts @@ -0,0 +1,129 @@ +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 PORT = 7965 +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() }, + } +} + +async function dial(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: 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 () => { + qemu = spawn('node', ['run.mjs'], { cwd: new URL('.', import.meta.url).pathname, stdio: 'ignore' }) + sock = await dial(120_000) + client = (await connectClient(socketLink(sock), WASM)).client +}) + +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/src/rynk/core.test.ts b/src/rynk/core.test.ts new file mode 100644 index 0000000..a7deff1 --- /dev/null +++ b/src/rynk/core.test.ts @@ -0,0 +1,78 @@ +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') + }) +}) diff --git a/src/stores/keyboard/errors.test.ts b/src/stores/keyboard/errors.test.ts new file mode 100644 index 0000000..204da78 --- /dev/null +++ b/src/stores/keyboard/errors.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { 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', () => { + const codes = [ + 'Busy', + 'Internal', + 'Invalid', + 'Locked', + 'Malformed', + 'NotReady', + 'StorageFault', + 'Unimplemented', + 'UnknownCmd', + ] + 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') + }) +}) diff --git a/src/stores/keyboard/keyboard.test.ts b/src/stores/keyboard/keyboard.test.ts new file mode 100644 index 0000000..2d45b84 --- /dev/null +++ b/src/stores/keyboard/keyboard.test.ts @@ -0,0 +1,313 @@ +import type { ConnectedDevice, DeviceCapabilities, KeyAction, RynkClient, 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, +} + +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 { + 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 + 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: 'fake', + serial_number: 'x', + }) + } + + get_capabilities() { return this.log('get_capabilities', 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: 0, 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: [] } + } + + 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('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('disconnect', () => { + it('keeps the label and frees the client', async () => { + const client = await connected() + await keyboardStore.disconnect() + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'fake' }) + 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/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, + }, +})) From fdac29bd26cf02255f03ec1e09405ea2957b44b3 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 22:35:00 +0800 Subject: [PATCH 05/10] docs: add the test plan, fix its unparseable jsonc fence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm-scripts block is a fragment of object properties, not a JSON document, so the jsonc parser eslint runs over fenced blocks failed on the first colon. Retagged as text — wrapping it in braces parses but then trips style/no-multi-spaces on the aligned values, and --fix would drop the alignment. --- TESTING.md | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 TESTING.md diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..efb4756 --- /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`(`build-rynk-wasm.py` 的 `resolve_repo()` 本就优先使用同级 `../rmk`),并生成 `.cargo/config.toml` 将固件的 `[patch]` 指向同一份,同时打印 `git -C ../rmk rev-parse HEAD` 便于定位。 + +顺带纳入(各一行,成本可忽略):现有 `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 生命周期无防护 | 本方案不覆盖,属已知缺口 | From 4d1c8674b924324a8dbf0da277008102b75b6ccf Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 23:35:22 +0800 Subject: [PATCH 06/10] docs: fix the development setup, document the test commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command in the Development section was inverted — web:dev/tauri:dev/ web:build/tauri:build, none of which exist; the scripts are dev:web, dev:tauri, build:web, build:tauri. Following the README got you four failures in a row. It also omitted build:wasm entirely. src/rynk/wasm/ is gitignored, so a fresh clone has no protocol client and dev:web fails on the first import. Notes that dev:tauri and build:tauri run their web counterparts through tauri.conf beforeDevCommand/beforeBuildCommand, so starting both duplicates the vite server. Adds a Testing section. test:qemu spawns the fixture itself, so it conflicts with a hand-started pnpm qemu — the serial port takes one client. Also flags CI=true for lint: the eslint config relaxes rules when it detects an editor. --- README.md | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f400802..8298860 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,46 @@ 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. Start the development server: +3. Build the protocol client: ```bash - pnpm web:dev - pnpm tauri:dev + pnpm build:wasm ``` -4. Build the application: + `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:build - pnpm tauri:build + pnpm dev:web # browser only + pnpm dev:tauri # desktop app — runs dev:web itself, don't start both ``` +5. Build the application: + ```bash + 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. Don't leave +`pnpm qemu` running alongside it — the fixture's serial port serves one client +at a time. + +`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 From 27849025bbc0335b44c67d409499a294b8beb721 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 23:43:21 +0800 Subject: [PATCH 07/10] docs: correct the stack described in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header, features and acknowledgements still described Nuxt and PrimeVue. Neither is a dependency — nor is Vue at all. The UI is Svelte 5 with Tailwind 4 and Ark UI. Also fixes the icon alt text (left over from Clash), an unclosed

, and a missing period in Install. --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8298860..c7992b4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@
-Clash -

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

+Gui configuration for RMK based on Tauri and Svelte +

## Warn @@ -14,13 +14,14 @@ safe distance. ## Install -Go to the release page to download the corresponding installation package Supports Windows (x64/x86), Linux (x64/arm64) +Go to the release page to download the corresponding installation package. Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple). ## Features - Based on Rust and Tauri2 frameworks. -- Concise and modern user interface. +- Concise and modern user interface, built with Svelte 5 and Tailwind CSS 4. +- Talks the rynk protocol over USB serial, BLE, and Web Serial. - Support for Windows, macOS, and Linux. ## Development @@ -86,8 +87,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, From 2ddd8bcb82863dae141aeda4eac368ddfee077b6 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 1 Aug 2026 23:59:33 +0800 Subject: [PATCH 08/10] fix: address the review on #83 - ci: least-privilege permissions, non-persisted checkout credentials, and a pinned cargo-binstall SHA. - ci/qemu: clone rmk once and point both the firmware and build:wasm at it via RMK_REPO, so the two artifacts cannot land on different protocol commits. qemu/run.mjs now resolves rmk the same way build-rynk-wasm.py does. - qemu: the smoke test allocates a free port and hands it to run.mjs instead of sharing a hardcoded 7965. - rynk: probeVersion takes a deadline, so a device that opens the port and never answers surfaces a transport error instead of parking the store in connecting. - store: endSession awaits the teardown and runs it on the failure path too; a not-connected reboot no longer relabels a link-lost phase. - ui: the picker distinguishes dismissal from a real failure and surfaces both it and a failed handshake; StateBar matches phases exhaustively and shows the error cause. - tests: cover the session-ending, storage-reset and BLE commands, the probe deadline, and derive the rynk code list from its source of truth. --- .github/workflows/ci.yml | 26 +++- README.md | 12 +- TESTING.md | 2 +- qemu/run.mjs | 30 ++++- qemu/smoke.test.ts | 27 ++++- src/App.svelte | 20 +++- src/components/StateBar.svelte | 15 ++- src/rynk/core.test.ts | 17 +++ src/rynk/core.ts | 56 ++++++--- src/stores/index.ts | 2 +- src/stores/keyboard/errors.test.ts | 30 +++-- src/stores/keyboard/errors.ts | 14 ++- src/stores/keyboard/index.ts | 2 +- src/stores/keyboard/keyboard.svelte.ts | 15 ++- src/stores/keyboard/keyboard.test.ts | 158 ++++++++++++++++++++++++- 15 files changed, 366 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9580b3..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 @@ -56,6 +62,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: pnpm/action-setup@v6 with: version: 11 @@ -63,7 +71,7 @@ jobs: with: node-version: 24 - - uses: cargo-bins/cargo-binstall@main + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - name: Install toolchains and qemu run: | @@ -72,6 +80,16 @@ jobs: 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 @@ -87,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 @@ -94,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 c7992b4..d778b12 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,15 @@ 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. Don't leave -`pnpm qemu` running alongside it — the fixture's serial port serves one client -at a time. +`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. diff --git a/TESTING.md b/TESTING.md index efb4756..e060971 100644 --- a/TESTING.md +++ b/TESTING.md @@ -251,7 +251,7 @@ wasm 预初始化:`mod.default({ module_or_path: await readFile(...) })`。此 **须一并处理的依赖一致性问题**:`.cargo/config.toml` 为 gitignore 的本地文件,因此 CI 中固件从 `git+rmk.git?branch=main` 解析,而 `scripts/build-rynk-wasm.py` 会**另外**再 clone 一次 `main`。两次独立拉取同一移动分支,若 `main` 在其间前进,固件与 wasm 客户端将来自不同的协议 commit,测试会以难以定位的线路错误失败。 -处理方式:CI 中只 clone 一次到 `../rmk`(`build-rynk-wasm.py` 的 `resolve_repo()` 本就优先使用同级 `../rmk`),并生成 `.cargo/config.toml` 将固件的 `[patch]` 指向同一份,同时打印 `git -C ../rmk rev-parse HEAD` 便于定位。 +处理方式: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。 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 index 8a77838..bf95f17 100644 --- a/qemu/smoke.test.ts +++ b/qemu/smoke.test.ts @@ -7,7 +7,6 @@ import process from 'node:process' import { afterAll, beforeAll, expect, it } from 'vitest' import { connectClient } from '../src/rynk/core' -const PORT = 7965 const WASM = readFileSync(new URL('../src/rynk/wasm/rynk_wasm_bg.wasm', import.meta.url)) // Mirrors qemu/src/main.rs. @@ -44,12 +43,25 @@ function socketLink(sock: net.Socket): JsByteLink { } } -async function dial(deadlineMs: number): Promise { +/// 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 +} + +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: PORT }) + const s = net.createConnection({ host: '127.0.0.1', port }) s.once('connect', () => res(s)) s.once('error', rej) }) @@ -66,8 +78,13 @@ let sock: net.Socket let client: RynkClient beforeAll(async () => { - qemu = spawn('node', ['run.mjs'], { cwd: new URL('.', import.meta.url).pathname, stdio: 'ignore' }) - sock = await dial(120_000) + const port = await freePort() + qemu = spawn('node', ['run.mjs'], { + cwd: new URL('.', import.meta.url).pathname, + stdio: 'ignore', + env: { ...process.env, RMK_QEMU_PORT: String(port) }, + }) + sock = await dial(port, 120_000) client = (await connectClient(socketLink(sock), WASM)).client }) diff --git a/src/App.svelte b/src/App.svelte index 480f8c3..d540336 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,9 +5,10 @@ import PageHost from './lib/PageHost.svelte' import Toaster from './lib/Toaster.svelte' import { canDiscover, connectWebSerial, discover } from './rynk' - import { keyboardStore } from './stores' + import { describeKeyboardError, keyboardStore } from './stores' let picking = $state(false) + let pickError = $state(null) $effect(() => { if (!canDiscover()) return @@ -20,10 +21,18 @@ // Must run from a click: the browser port picker requires a user gesture. async function pickWebSerial() { picking = true + pickError = null try { - await keyboardStore.initStore(await connectWebSerial()) + // 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) } - catch { /* user dismissed the picker */ } finally { picking = false } @@ -34,7 +43,7 @@
{#if !canDiscover() && keyboardStore.connection?.phase !== 'connected'} -
+
{:else} diff --git a/src/components/StateBar.svelte b/src/components/StateBar.svelte index d874bfb..c927f43 100644 --- a/src/components/StateBar.svelte +++ b/src/components/StateBar.svelte @@ -1,13 +1,18 @@ diff --git a/src/rynk/core.test.ts b/src/rynk/core.test.ts index a7deff1..e11add2 100644 --- a/src/rynk/core.test.ts +++ b/src/rynk/core.test.ts @@ -75,4 +75,21 @@ describe('probeVersion', () => { 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 14b707a..5837b8d 100644 --- a/src/rynk/core.ts +++ b/src/rynk/core.ts @@ -7,6 +7,10 @@ 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. export function cobsEncode(data: Uint8Array): Uint8Array { const out = [0] @@ -54,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]. -export 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) } } @@ -83,8 +107,8 @@ async function loadCore(major: number) { /// `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) { - const { major, minor } = await probeVersion(link) +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(wasm ? { module_or_path: wasm } : undefined) const client = await core.connect(link) 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 index 204da78..3e6f904 100644 --- a/src/stores/keyboard/errors.test.ts +++ b/src/stores/keyboard/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toKeyboardError } from './errors' +import { describeKeyboardError, RYNK_ERROR_CODES, toKeyboardError } from './errors' function named(name: string, message: string): Error { const e = new Error(message) @@ -9,17 +9,10 @@ function named(name: string, message: string): Error { describe('toKeyboardError', () => { it('maps every rynk rejection code, including Busy', () => { - const codes = [ - 'Busy', - 'Internal', - 'Invalid', - 'Locked', - 'Malformed', - 'NotReady', - 'StorageFault', - 'Unimplemented', - 'UnknownCmd', - ] + // 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 }) @@ -42,3 +35,16 @@ describe('toKeyboardError', () => { 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 06661ca..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 } @@ -7,7 +8,8 @@ export type KeyboardError | { type: 'unknown', cause: unknown } // A Record (not an array) so a new RynkError variant upstream fails this build. -const RYNK_ERROR_CODES: Record = { +// Exported so tests iterate this list instead of keeping a stale copy. +export const RYNK_ERROR_CODES: Record = { Busy: true, Internal: true, Invalid: true, @@ -42,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 6d8ced1..e21816e 100644 --- a/src/stores/keyboard/keyboard.svelte.ts +++ b/src/stores/keyboard/keyboard.svelte.ts @@ -591,11 +591,22 @@ class KeyboardStoreClass { } /// Reboot and bootloader jump are fire-and-forget upstream — the device resets - /// before it can reply, so the ack may never land. Either way the session ends. + /// 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) - .andTee(() => { void this.teardown({ phase: 'disconnected', label }) }) + .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)))) } } diff --git a/src/stores/keyboard/keyboard.test.ts b/src/stores/keyboard/keyboard.test.ts index 2d45b84..d43113d 100644 --- a/src/stores/keyboard/keyboard.test.ts +++ b/src/stores/keyboard/keyboard.test.ts @@ -1,4 +1,4 @@ -import type { ConnectedDevice, DeviceCapabilities, KeyAction, RynkClient, TopicEvent } from '../../rynk' +import type { ConnectedDevice, DeviceCapabilities, KeyAction, RynkClient, StorageResetMode, TopicEvent } from '../../rynk' import { beforeEach, describe, expect, it, vi } from 'vitest' const connectClient = vi.hoisted(() => vi.fn()) @@ -30,6 +30,8 @@ const CAPS: DeviceCapabilities = { 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 @@ -39,6 +41,7 @@ function rejection(name: string, message: string): Error { /// 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 keymap: KeyAction[] = ['No', 'No'] locked = false freed = false @@ -47,6 +50,9 @@ class FakeClient { /// 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 { @@ -66,7 +72,7 @@ class FakeClient { }) } - get_capabilities() { return this.log('get_capabilities', CAPS) } + 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) } @@ -83,7 +89,7 @@ class FakeClient { } get_battery_status() { return this.log('get_battery_status', 'Unavailable') } - get_ble_status() { return this.log('get_ble_status', { profile: 0, state: 'Inactive' }) } + 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', @@ -135,6 +141,34 @@ class FakeClient { 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 @@ -296,6 +330,124 @@ describe('link death', () => { }) }) +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: 'fake' }) + 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: 'fake' }) + 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: 'fake' }) + 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() From 742e3f7b364adc0417cbd0a037e204ab4f62e355 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sun, 2 Aug 2026 00:16:01 +0800 Subject: [PATCH 09/10] fix(store): label the session with the keyboard's reported name Web Serial's getInfo() exposes no string descriptors, so connectWebSerial can only offer the constant 'WebSerial' and the status bar showed that instead of the device. The handshake already reads product_name via get_device_info; use it for the connected phase and keep the transport label as the fallback and for the pre-handshake phases. --- src/stores/keyboard/keyboard.svelte.ts | 6 ++++- src/stores/keyboard/keyboard.test.ts | 36 ++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/stores/keyboard/keyboard.svelte.ts b/src/stores/keyboard/keyboard.svelte.ts index e21816e..191b339 100644 --- a/src/stores/keyboard/keyboard.svelte.ts +++ b/src/stores/keyboard/keyboard.svelte.ts @@ -269,7 +269,11 @@ class KeyboardStoreClass { 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 diff --git a/src/stores/keyboard/keyboard.test.ts b/src/stores/keyboard/keyboard.test.ts index d43113d..2b00fb0 100644 --- a/src/stores/keyboard/keyboard.test.ts +++ b/src/stores/keyboard/keyboard.test.ts @@ -42,6 +42,9 @@ function rejection(name: string, message: string): Error { /// 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 @@ -67,7 +70,7 @@ class FakeClient { vendor_id: 1, product_id: 2, manufacturer: 'test', - product_name: 'fake', + product_name: this.productName, serial_number: 'x', }) } @@ -212,6 +215,29 @@ describe('connect', () => { 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 @@ -411,7 +437,7 @@ describe('session-ending commands', () => { 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: 'fake' }) + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) expect(keyboardStore.device).toBeNull() expect(keyboardStore.config).toBeNull() expect(client.freed).toBe(true) @@ -423,7 +449,7 @@ describe('session-ending commands', () => { 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: 'fake' }) + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) expect(client.freed).toBe(true) }) } @@ -434,7 +460,7 @@ describe('session-ending commands', () => { 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: 'fake' }) + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) expect(client.freed).toBe(true) }) @@ -452,7 +478,7 @@ 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: 'fake' }) + expect(keyboardStore.connection).toEqual({ phase: 'disconnected', label: 'Fake60' }) expect(keyboardStore.config).toBeNull() expect(client.freed).toBe(true) }) From 5c912a0a2d08189042dafa04c59928eade62daf4 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sun, 2 Aug 2026 00:31:41 +0800 Subject: [PATCH 10/10] test(qemu): wait for the fixture's UART before probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qemu binds the serial socket during machine init, so the harness could connect and send GET_VERSION before the guest booted — and Uart::new() resets the 16550 FIFO, discarding it. The fixture never answered and the probe burned its whole deadline; CI failed this way once out of several runs. The fixture now prints a marker once its UART is up and the harness waits for it before dialling. Kept out of probeVersion on purpose: a real keyboard is long past boot by the time a host connects. Also hold run.mjs's output and dump it when the handshake fails — the previous run was undiagnosable because cargo and semihosting output went to /dev/null. --- qemu/smoke.test.ts | 33 ++++++++++++++++++++++++++++++--- qemu/src/main.rs | 4 ++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/qemu/smoke.test.ts b/qemu/smoke.test.ts index bf95f17..e5802f3 100644 --- a/qemu/smoke.test.ts +++ b/qemu/smoke.test.ts @@ -56,6 +56,20 @@ async function freePort(): Promise { 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 (;;) { @@ -81,11 +95,24 @@ beforeAll(async () => { const port = await freePort() qemu = spawn('node', ['run.mjs'], { cwd: new URL('.', import.meta.url).pathname, - stdio: 'ignore', + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, RMK_QEMU_PORT: String(port) }, }) - sock = await dial(port, 120_000) - client = (await connectClient(socketLink(sock), WASM)).client + // 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(() => { 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();