diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7a9b896..0e3069a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 12e6e88f..90938e53 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -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/.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 diff --git a/docs/adr/0054-deterministic-shortcut-qa-matrix.md b/docs/adr/0054-deterministic-shortcut-qa-matrix.md new file mode 100644 index 00000000..92293b8d --- /dev/null +++ b/docs/adr/0054-deterministic-shortcut-qa-matrix.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6b9df59c..b7c4877b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index 40d3628e..cb1e7683 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -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 +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( .map(([id]) => id), ) +const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set([ + 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(), 'command-or-ctrl-shift': new Map(), @@ -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, diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 8d9260a6..c06c6d82 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -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({ diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 86ec58ec..521cbad7 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -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 + triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void } } } diff --git a/src/main.tsx b/src/main.tsx index eb66760b..866a58d5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 + 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( diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index 8b2987b8..153b1132 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -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 { @@ -388,3 +395,5 @@ export async function openFixtureVaultTauri( await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__)) } + +export const openFixtureVaultTauri = openFixtureVaultDesktopHarness diff --git a/tests/smoke/keyboard-command-routing.spec.ts b/tests/smoke/keyboard-command-routing.spec.ts index 43be0d20..ff7b0f84 100644 --- a/tests/smoke/keyboard-command-routing.spec.ts +++ b/tests/smoke/keyboard-command-routing.spec.ts @@ -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 }) }) }) diff --git a/tests/smoke/testBridge.ts b/tests/smoke/testBridge.ts index e9b11ced..1a83e138 100644 --- a/tests/smoke/testBridge.ts +++ b/tests/smoke/testBridge.ts @@ -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 { await page.evaluate(async (commandId) => { @@ -32,3 +37,30 @@ export async function triggerMenuCommand(page: Page, id: string): Promise throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand') }, id) } + +export async function dispatchShortcutEvent( + page: Page, + init: AppCommandShortcutEventInit, +): Promise { + 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 { + 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 }) +}