refactor: make shortcut QA modes explicit

This commit is contained in:
lucaronin
2026-04-11 19:03:15 +02:00
parent f694b9b5e4
commit 258b54b074
11 changed files with 270 additions and 30 deletions

View File

@@ -729,12 +729,15 @@ Selection-dependent note actions are wired through both the command palette and
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs and modifier rules
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses both real key events in a Tauri-like environment and `trigger_menu_command` to prove the keyboard path and the native menu path without relying on flaky macOS key synthesis
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
- native menu-command proof through `trigger_menu_command`
- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical
## Auto-Release & In-App Updates

View File

@@ -282,11 +282,16 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
For automated QA, prefer real key events in a Tauri-like environment for shortcut behavior and `window.__laputaTest.triggerMenuCommand()` for the native menu click path. For macOS browser-reserved chords, also perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage
That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
## Running Tests
@@ -341,7 +346,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID and modifier rule, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0054"
title: "Deterministic shortcut QA matrix"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy:
- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA
- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell
- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use
That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven.
## Decision
**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.**
## Options considered
- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS.
- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely.
- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today.
## Consequences
- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command.
- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA.
- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis.
- Native menu-command behavior can be verified deterministically through the Tauri command bridge.
- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords.

View File

@@ -109,3 +109,4 @@ proposed → active → superseded
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) |
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |

View File

@@ -51,8 +51,27 @@ export type AppCommandShortcutCombo =
| 'command-or-ctrl'
| 'command-or-ctrl-shift'
| 'command-shift'
export type AppCommandDeterministicQaMode =
| 'renderer-shortcut-event'
| 'native-menu-command'
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
export interface AppCommandDeterministicQaDefinition {
preferredMode: AppCommandDeterministicQaMode
supportsRendererShortcutEvent: boolean
supportsNativeMenuCommand: boolean
requiresManualNativeAcceleratorQa: boolean
}
export interface AppCommandShortcutEventOptions {
preferControl?: boolean
}
export type AppCommandShortcutEventInit = Pick<
KeyboardEventInit,
'altKey' | 'bubbles' | 'cancelable' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'
>
type SimpleHandlerKey =
| 'onOpenSettings'
| 'onCheckForUpdates'
@@ -312,6 +331,20 @@ const NATIVE_MENU_COMMAND_SET = new Set<string>(
.map(([id]) => id),
)
const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set<AppCommandId>([
APP_COMMAND_IDS.appSettings,
APP_COMMAND_IDS.fileNewNote,
APP_COMMAND_IDS.fileDailyNote,
APP_COMMAND_IDS.fileQuickOpen,
APP_COMMAND_IDS.fileSave,
APP_COMMAND_IDS.editFindInVault,
APP_COMMAND_IDS.viewToggleProperties,
APP_COMMAND_IDS.viewToggleAiChat,
APP_COMMAND_IDS.viewCommandPalette,
APP_COMMAND_IDS.noteToggleOrganized,
APP_COMMAND_IDS.noteToggleFavorite,
])
const shortcutKeyMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
@@ -353,6 +386,41 @@ export function isNativeMenuCommandId(value: string): value is AppCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function getDeterministicShortcutQaDefinition(
id: AppCommandId,
): AppCommandDeterministicQaDefinition | null {
const definition = APP_COMMAND_DEFINITIONS[id]
if (!definition.shortcut) return null
return {
preferredMode: definition.menuOwned ? 'native-menu-command' : 'renderer-shortcut-event',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: definition.menuOwned,
requiresManualNativeAcceleratorQa: MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET.has(id),
}
}
export function getShortcutEventInit(
id: AppCommandId,
options: AppCommandShortcutEventOptions = {},
): AppCommandShortcutEventInit | null {
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
if (!shortcut) return null
const useControl = options.preferControl ?? false
return {
key: shortcut.key,
code: shortcut.code,
altKey: false,
bubbles: true,
cancelable: true,
ctrlKey: useControl,
metaKey: !useControl,
shiftKey: shortcut.combo !== 'command-or-ctrl',
}
}
export function shortcutCombosForEvent({
altKey,
ctrlKey,

View File

@@ -10,6 +10,10 @@ import {
resetAppCommandDispatchStateForTests,
type AppCommandHandlers,
} from './appCommandDispatcher'
import {
getDeterministicShortcutQaDefinition,
getShortcutEventInit,
} from './appCommandCatalog'
function makeHandlers(): AppCommandHandlers {
return {
@@ -73,6 +77,39 @@ describe('appCommandDispatcher', () => {
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
it('gives every shortcut command an explicit deterministic QA strategy', () => {
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.fileNewNote)).toMatchObject({
preferredMode: 'native-menu-command',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: true,
requiresManualNativeAcceleratorQa: true,
})
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.noteToggleFavorite)).toMatchObject({
preferredMode: 'renderer-shortcut-event',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: false,
requiresManualNativeAcceleratorQa: true,
})
})
it('builds deterministic keyboard events from the shared shortcut manifest', () => {
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat)).toMatchObject({
key: 'l',
code: 'KeyL',
metaKey: true,
ctrlKey: false,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat, { preferControl: true })).toMatchObject({
key: 'l',
code: 'KeyL',
metaKey: false,
ctrlKey: true,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.appCheckForUpdates)).toBeNull()
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
import {
APP_COMMAND_EVENT_NAME,
executeAppCommand,
@@ -11,8 +12,10 @@ declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}

View File

@@ -8,13 +8,20 @@ import {
isAppCommandId,
isNativeMenuCommandId,
} from './hooks/appCommandDispatcher'
import {
getShortcutEventInit,
type AppCommandShortcutEventInit,
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
@@ -27,6 +34,15 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
}
function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) {
const target =
document.activeElement instanceof HTMLElement
? document.activeElement
: document.body ?? window
target.dispatchEvent(new KeyboardEvent('keydown', init))
}
window.__laputaTest = {
dispatchAppCommand(id: string) {
if (!isAppCommandId(id)) {
@@ -34,6 +50,9 @@ window.__laputaTest = {
}
window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, { detail: id }))
},
dispatchShortcutEvent(init: AppCommandShortcutEventInit) {
dispatchDeterministicShortcutEvent(init)
},
async triggerMenuCommand(id: string) {
if (!isNativeMenuCommandId(id)) {
throw new Error(`Unknown native menu command: ${id}`)
@@ -51,6 +70,18 @@ window.__laputaTest = {
window.__laputaTest.dispatchBrowserMenuCommand(id)
return undefined
},
triggerShortcutCommand(id: string, options?: AppCommandShortcutEventOptions) {
if (!isAppCommandId(id)) {
throw new Error(`Unknown app command: ${id}`)
}
const init = getShortcutEventInit(id, options)
if (!init) {
throw new Error(`Command ${id} does not define a keyboard shortcut`)
}
dispatchDeterministicShortcutEvent(init)
},
}
createRoot(document.getElementById('root')!).render(

View File

@@ -120,7 +120,14 @@ export async function openFixtureVault(
})
}
export async function openFixtureVaultTauri(
/**
* Browser harness for desktop command-routing tests.
*
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
* renderer shortcut dispatch and desktop menu-command dispatch without a native
* shell. It is deterministic, but it is not a substitute for real native QA.
*/
export async function openFixtureVaultDesktopHarness(
page: Page,
vaultPath: string,
): Promise<void> {
@@ -388,3 +395,5 @@ export async function openFixtureVaultTauri(
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
}
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness

View File

@@ -1,6 +1,15 @@
import { test, expect } from '@playwright/test'
import { triggerMenuCommand } from './testBridge'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import {
dispatchShortcutEvent,
triggerMenuCommand,
triggerShortcutCommand,
} from './testBridge'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
let tempVaultDir: string
@@ -13,72 +22,79 @@ test.describe('keyboard command routing', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('native menu trigger creates a note through the shared command path @smoke', async ({ page }) => {
test('desktop menu-command bridge creates a note through the shared command path @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openFixtureVaultTauri(page, tempVaultDir)
await triggerMenuCommand(page, 'file-new-note')
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await triggerMenuCommand(page, APP_COMMAND_IDS.fileNewNote)
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { timeout: 5_000 })
expect(errors).toEqual([])
})
test('Meta+Shift+I toggles the properties panel in Tauri mode through the shared keyboard path @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('desktop menu-command bridge toggles the properties panel through the shared command path @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await page.keyboard.press('Meta+Shift+I')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press('Meta+Shift+I')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
})
test('native menu trigger toggles organized state through the shared command path @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('desktop menu-command bridge toggles organized state through the shared command path @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
// Chromium reserves Meta+E for the browser chrome, so the exact keystroke
// needs native-app QA. The smoke suite proves the shared command path here.
await triggerMenuCommand(page, 'note-toggle-organized')
await triggerMenuCommand(page, APP_COMMAND_IDS.noteToggleOrganized)
await expect(page.getByTitle('Mark as unorganized (back to Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, 'note-toggle-organized')
await triggerMenuCommand(page, APP_COMMAND_IDS.noteToggleOrganized)
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
})
test('Meta+Backslash toggles the raw editor in Tauri mode through the shared keyboard path @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('renderer shortcut bridge toggles the raw editor through the shared keyboard handler @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await page.keyboard.press('Meta+Backslash')
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press('Meta+Backslash')
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
})
test('Meta+Shift+L toggles the AI panel in Tauri mode, while Ctrl+Shift+L does not @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('desktop menu-command bridge toggles the AI panel, while the wrong modifier event does not @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await page.keyboard.press('Control+Shift+L')
await dispatchShortcutEvent(page, {
key: 'l',
code: 'KeyL',
ctrlKey: true,
metaKey: false,
shiftKey: true,
altKey: false,
bubbles: true,
cancelable: true,
})
await page.waitForTimeout(200)
await expect(page.getByTestId('ai-panel')).not.toBeVisible()
await page.keyboard.press('Meta+Shift+L')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleAiChat)
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTitle('Close AI panel')).toBeVisible()
await page.keyboard.press('Meta+Shift+L')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleAiChat)
await expect(page.getByTestId('ai-panel')).not.toBeVisible({ timeout: 5_000 })
})
})

View File

@@ -1,4 +1,9 @@
import { type Page } from '@playwright/test'
import type {
AppCommandId,
AppCommandShortcutEventInit,
AppCommandShortcutEventOptions,
} from '../../src/hooks/appCommandCatalog'
export async function triggerMenuCommand(page: Page, id: string): Promise<void> {
await page.evaluate(async (commandId) => {
@@ -32,3 +37,30 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
}, id)
}
export async function dispatchShortcutEvent(
page: Page,
init: AppCommandShortcutEventInit,
): Promise<void> {
await page.evaluate((eventInit) => {
const bridge = window.__laputaTest?.dispatchShortcutEvent
if (typeof bridge !== 'function') {
throw new Error('Laputa test bridge is missing dispatchShortcutEvent')
}
bridge(eventInit)
}, init)
}
export async function triggerShortcutCommand(
page: Page,
id: AppCommandId,
options?: AppCommandShortcutEventOptions,
): Promise<void> {
await page.evaluate((payload) => {
const bridge = window.__laputaTest?.triggerShortcutCommand
if (typeof bridge !== 'function') {
throw new Error('Laputa test bridge is missing triggerShortcutCommand')
}
bridge(payload.id, payload.options)
}, { id, options })
}