| 1 | // Run: tsx src/__tests__/keyboard-shortcuts.test.ts |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | |
| 5 | import { |
| 6 | defaultShortcutCombo, |
| 7 | formatShortcutCombo, |
| 8 | formatShortcutComboParts, |
| 9 | isCloseTabShortcut, |
| 10 | isReservedComposerHistoryShortcut, |
| 11 | matchesShortcut, |
| 12 | shortcutAcceptsCombo, |
| 13 | shortcutConflict, |
| 14 | shortcutDefinition, |
| 15 | type ShortcutPlatform, |
| 16 | } from "../lib/keyboardShortcuts"; |
| 17 | import { topicShortcutIndexFromEvent, topicShortcutLabel } from "../lib/topicShortcuts"; |
| 18 | |
| 19 | let passed = 0; |
| 20 | let failed = 0; |
| 21 | |
| 22 | function eq(a: unknown, b: unknown, label: string) { |
| 23 | if (a === b) { |
| 24 | process.stdout.write(` PASS ${label}\n`); |
| 25 | passed += 1; |
| 26 | } else { |
| 27 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 28 | failed += 1; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function event(key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean } = {}) { |
| 33 | return { |
| 34 | key, |
| 35 | ctrlKey: modifiers.ctrlKey ?? false, |
| 36 | metaKey: modifiers.metaKey ?? false, |
| 37 | altKey: modifiers.altKey ?? false, |
| 38 | shiftKey: modifiers.shiftKey ?? false, |
| 39 | defaultPrevented: modifiers.defaultPrevented ?? false, |
| 40 | target: null, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | function eventWithTarget( |
| 45 | key: string, |
| 46 | target: EventTarget | null, |
| 47 | modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean } = {}, |
| 48 | ) { |
| 49 | return { |
| 50 | key, |
| 51 | ctrlKey: modifiers.ctrlKey ?? false, |
| 52 | metaKey: modifiers.metaKey ?? false, |
| 53 | altKey: modifiers.altKey ?? false, |
| 54 | shiftKey: modifiers.shiftKey ?? false, |
| 55 | defaultPrevented: modifiers.defaultPrevented ?? false, |
| 56 | target, |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | console.log("\nkeyboard shortcuts"); |
| 61 | |
| 62 | eq(isCloseTabShortcut(event("w", { metaKey: true }), "darwin"), true, "Cmd+W closes tabs on macOS"); |
| 63 | eq(isCloseTabShortcut(event("W", { metaKey: true }), "darwin"), true, "Cmd+Shift+W key value still matches W on macOS"); |
| 64 | eq(isCloseTabShortcut(event("w", { ctrlKey: true }), "darwin"), false, "Control+W does not close tabs on macOS"); |
| 65 | eq(isCloseTabShortcut(event("w", { metaKey: true }), "windows"), false, "Meta+W does not close tabs on Windows"); |
| 66 | eq(isCloseTabShortcut(event("w", { ctrlKey: true }), "windows"), true, "Ctrl+W closes tabs on Windows"); |
| 67 | eq(isCloseTabShortcut(event("w", { ctrlKey: true }), "linux"), true, "Ctrl+W closes tabs on Linux"); |
| 68 | |
| 69 | for (const platform of ["darwin", "windows", "linux"] satisfies ShortcutPlatform[]) { |
| 70 | eq(isCloseTabShortcut(event("k", { ctrlKey: true, metaKey: true }), platform), false, `${platform} ignores non-W keys`); |
| 71 | eq(isCloseTabShortcut(event("w"), platform), false, `${platform} requires the platform modifier`); |
| 72 | } |
| 73 | |
| 74 | eq(matchesShortcut(event("k", { metaKey: true }), "commandPalette.open", "darwin"), true, "Cmd+K opens the palette on macOS"); |
| 75 | eq(matchesShortcut(event("k", { ctrlKey: true }), "commandPalette.open", "windows"), true, "Ctrl+K opens the palette on Windows"); |
| 76 | eq(matchesShortcut({ key: "?", shiftKey: true }, "shortcuts.show", "darwin"), true, "? opens shortcut help"); |
| 77 | eq(matchesShortcut({ key: "+", metaKey: true, shiftKey: true }, "textSize.increase", "darwin"), true, "Cmd+Plus still increases text size"); |
| 78 | eq(formatShortcutCombo(defaultShortcutCombo("settings.open", "darwin"), "darwin"), "⌘,", "formats mac settings shortcut"); |
| 79 | eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open", "darwin"), "darwin")), JSON.stringify(["⌘", ","]), "splits mac settings shortcut for display"); |
| 80 | eq(formatShortcutCombo(defaultShortcutCombo("settings.open", "windows"), "windows"), "Ctrl+,", "formats Windows settings shortcut"); |
| 81 | eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open", "windows"), "windows")), JSON.stringify(["Ctrl", ","]), "splits Windows settings shortcut for display"); |
| 82 | eq(shortcutConflict("settings.open", defaultShortcutCombo("commandPalette.open", "darwin"), "darwin")?.action, "commandPalette.open", "detects shortcut conflicts"); |
| 83 | eq(matchesShortcut(event("l", { metaKey: true }), "selection.addToChat", "darwin"), true, "Cmd+L adds the selection to chat on macOS"); |
| 84 | eq(matchesShortcut(event("l", { ctrlKey: true }), "selection.addToChat", "windows"), true, "Ctrl+L adds the selection to chat on Windows"); |
| 85 | eq(matchesShortcut(event("l", { ctrlKey: true, metaKey: true }), "selection.addToChat", "darwin"), false, "extra modifiers do not trigger the selection shortcut"); |
| 86 | eq(shortcutConflict("app.newSession", { key: "l", ctrl: true }, "linux")?.action, "selection.addToChat", "rebinding another action onto Ctrl+L conflicts with the selection shortcut"); |
| 87 | eq(topicShortcutIndexFromEvent(event("1", { metaKey: true }), "darwin"), 0, "Cmd+1 maps to the first topic shortcut on macOS"); |
| 88 | eq(topicShortcutIndexFromEvent(event("1", { ctrlKey: true }), "darwin"), null, "Ctrl+1 is not a topic shortcut on macOS"); |
| 89 | eq(topicShortcutIndexFromEvent(event("9", { ctrlKey: true }), "windows"), 8, "Ctrl+9 maps to the ninth topic shortcut on Windows"); |
| 90 | eq(topicShortcutIndexFromEvent(event("9", { metaKey: true }), "windows"), null, "Meta+9 is not a topic shortcut on Windows"); |
| 91 | eq(topicShortcutIndexFromEvent(event("1", { ctrlKey: true, shiftKey: true }), "linux"), null, "topic shortcuts reject extra modifiers"); |
| 92 | eq(topicShortcutIndexFromEvent(event("0", { metaKey: true }), "darwin"), null, "Cmd+0 is not a topic shortcut"); |
| 93 | eq(topicShortcutIndexFromEvent(event("1", { metaKey: true, defaultPrevented: true }), "darwin"), null, "topic shortcuts yield to already-handled custom shortcuts"); |
| 94 | { |
| 95 | const dom = new JSDOM("<!doctype html><html><body><input /><textarea></textarea></body></html>"); |
| 96 | const previousHTMLElement = globalThis.HTMLElement; |
| 97 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 98 | const input = dom.window.document.querySelector("input"); |
| 99 | const textarea = dom.window.document.querySelector("textarea"); |
| 100 | eq(topicShortcutIndexFromEvent(eventWithTarget("1", input, { metaKey: true }), "darwin"), 0, "Cmd+1 still works when an input has focus"); |
| 101 | eq(topicShortcutIndexFromEvent(eventWithTarget("9", textarea, { ctrlKey: true }), "windows"), 8, "Ctrl+9 still works when a textarea has focus"); |
| 102 | globalThis.HTMLElement = previousHTMLElement; |
| 103 | } |
| 104 | eq(topicShortcutLabel(1, "darwin"), "⌘1", "topic badge uses the macOS command glyph"); |
| 105 | eq(topicShortcutLabel(1, "windows"), "Ctrl+1", "topic badge uses the Windows control modifier"); |
| 106 | |
| 107 | for (const platform of ["darwin", "windows", "linux"] satisfies ShortcutPlatform[]) { |
| 108 | eq(matchesShortcut(event("Enter", { shiftKey: true }), "composer.newline", platform), true, `${platform} newline chord defaults to Shift+Enter`); |
| 109 | eq(matchesShortcut(event("Enter"), "composer.newline", platform), false, `${platform} plain Enter is not the default newline chord`); |
| 110 | eq(matchesShortcut(event("Enter", { ctrlKey: true }), "composer.newline", platform), false, `${platform} Ctrl+Enter is not the default newline chord`); |
| 111 | eq(matchesShortcut(event("Enter"), "composer.send", platform), true, `${platform} send chord defaults to plain Enter`); |
| 112 | eq(matchesShortcut(event("Enter", { shiftKey: true }), "composer.send", platform), false, `${platform} Shift+Enter is not the default send chord`); |
| 113 | } |
| 114 | eq(shortcutConflict("app.newSession", { key: "Enter", shift: true }, "darwin")?.action, "composer.newline", "rebinding another action onto Shift+Enter conflicts with the newline chord"); |
| 115 | eq(shortcutConflict("composer.newline", { key: "Enter" }, "darwin")?.action, "composer.send", "rebinding the newline chord onto plain Enter conflicts with the send chord"); |
| 116 | eq(shortcutAcceptsCombo("composer.send", { key: "Enter", ctrl: true }), true, "composer send accepts modified Enter"); |
| 117 | eq(shortcutAcceptsCombo("composer.newline", { key: "Enter", alt: true }), true, "composer newline accepts modified Enter"); |
| 118 | eq(shortcutAcceptsCombo("composer.send", { key: "s", ctrl: true }), false, "composer send rejects non-Enter keys"); |
| 119 | eq(shortcutAcceptsCombo("app.newSession", { key: "s", ctrl: true }), true, "unrestricted shortcuts still accept other keys"); |
| 120 | eq(formatShortcutCombo(defaultShortcutCombo("composer.newline", "darwin"), "darwin"), "⇧Enter", "formats the mac newline chord"); |
| 121 | eq(formatShortcutCombo(defaultShortcutCombo("composer.newline", "windows"), "windows"), "Shift+Enter", "formats the Windows newline chord"); |
| 122 | eq(matchesShortcut(event("z", { metaKey: true }), "composer.undo", "darwin"), true, "Cmd+Z matches composer undo on macOS"); |
| 123 | eq(matchesShortcut(event("z", { ctrlKey: true }), "composer.undo", "windows"), true, "Ctrl+Z matches composer undo on Windows"); |
| 124 | eq(matchesShortcut(event("z", { metaKey: true, shiftKey: true }), "composer.redo", "darwin"), true, "Cmd+Shift+Z matches composer redo on macOS"); |
| 125 | eq(matchesShortcut(event("z", { ctrlKey: true, shiftKey: true }), "composer.redo", "linux"), true, "Ctrl+Shift+Z matches composer redo on Linux"); |
| 126 | eq(matchesShortcut(event("y", { ctrlKey: true }), "composer.redo", "windows"), false, "Ctrl+Y remains a Composer-level conditional fallback"); |
| 127 | eq(isReservedComposerHistoryShortcut(event("z", { metaKey: true }), "darwin"), true, "macOS undo is reserved while editing"); |
| 128 | eq(isReservedComposerHistoryShortcut(event("z", { ctrlKey: true, shiftKey: true }), "windows"), true, "Windows redo is reserved while editing"); |
| 129 | eq(isReservedComposerHistoryShortcut(event("y", { ctrlKey: true }), "windows"), false, "conditional Ctrl+Y redo does not reserve a global shortcut"); |
| 130 | eq(shortcutDefinition("composer.undo").configurable, false, "composer undo is visible but locked to the native editing chord"); |
| 131 | eq(shortcutDefinition("composer.redo").configurable, false, "composer redo is visible but locked to the native editing chord"); |
| 132 | eq(shortcutConflict("app.newSession", { key: "z", ctrl: true }, "windows")?.action, "composer.undo", "custom shortcuts cannot take the Windows undo chord"); |
| 133 | eq(shortcutConflict("app.newSession", { key: "z", ctrl: true, shift: true }, "windows")?.action, "composer.redo", "custom shortcuts cannot take the Windows redo chord"); |
| 134 | |
| 135 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 136 | if (failed > 0) process.exit(1); |
| 137 |