experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: add unit tests for modes and shortcuts (57 tests)

+755 -14
+309
backend/electron/modes.test.ts
··· 1 + /** 2 + * Unit tests for Modes module 3 + * Tests the mode manager, mode state tracking, and mode-conditional logic 4 + */ 5 + 6 + import { describe, it, before, after, beforeEach } from 'node:test'; 7 + import * as assert from 'node:assert'; 8 + 9 + // Import will be done after ensuring module is compiled 10 + let modes: typeof import('./modes.js'); 11 + 12 + describe('Modes Module Tests', () => { 13 + before(async () => { 14 + // Dynamic import of the compiled module 15 + modes = await import('./modes.js'); 16 + }); 17 + 18 + describe('getWindowModeState', () => { 19 + it('should return default mode state for new window', () => { 20 + const state = modes.getWindowModeState(9999); 21 + assert.strictEqual(state.major, 'default'); 22 + assert.deepStrictEqual(state.minors, []); 23 + }); 24 + 25 + it('should return a copy, not the original state', () => { 26 + const state1 = modes.getWindowModeState(9998); 27 + const state2 = modes.getWindowModeState(9998); 28 + 29 + // Should be equal but not the same object 30 + assert.deepStrictEqual(state1, state2); 31 + assert.notStrictEqual(state1, state2); 32 + assert.notStrictEqual(state1.minors, state2.minors); 33 + }); 34 + }); 35 + 36 + describe('setMajorMode', () => { 37 + it('should set major mode for a window', () => { 38 + modes.setMajorMode(1001, 'page'); 39 + const state = modes.getWindowModeState(1001); 40 + assert.strictEqual(state.major, 'page'); 41 + }); 42 + 43 + it('should update existing major mode', () => { 44 + modes.setMajorMode(1002, 'page'); 45 + modes.setMajorMode(1002, 'group'); 46 + const state = modes.getWindowModeState(1002); 47 + assert.strictEqual(state.major, 'group'); 48 + }); 49 + 50 + it('should preserve minor modes when changing major mode', () => { 51 + modes.setMajorMode(1003, 'page'); 52 + modes.enableMinorMode(1003, 'edit'); 53 + modes.setMajorMode(1003, 'settings'); 54 + 55 + const state = modes.getWindowModeState(1003); 56 + assert.strictEqual(state.major, 'settings'); 57 + assert.ok(state.minors.includes('edit')); 58 + }); 59 + }); 60 + 61 + describe('enableMinorMode', () => { 62 + it('should enable a minor mode', () => { 63 + modes.setMajorMode(2001, 'default'); 64 + modes.enableMinorMode(2001, 'preview'); 65 + 66 + const state = modes.getWindowModeState(2001); 67 + assert.ok(state.minors.includes('preview')); 68 + }); 69 + 70 + it('should allow multiple minor modes', () => { 71 + modes.setMajorMode(2002, 'page'); 72 + modes.enableMinorMode(2002, 'preview'); 73 + modes.enableMinorMode(2002, 'edit'); 74 + modes.enableMinorMode(2002, 'search'); 75 + 76 + const state = modes.getWindowModeState(2002); 77 + assert.strictEqual(state.minors.length, 3); 78 + assert.ok(state.minors.includes('preview')); 79 + assert.ok(state.minors.includes('edit')); 80 + assert.ok(state.minors.includes('search')); 81 + }); 82 + 83 + it('should not duplicate minor modes', () => { 84 + modes.setMajorMode(2003, 'page'); 85 + modes.enableMinorMode(2003, 'edit'); 86 + modes.enableMinorMode(2003, 'edit'); 87 + 88 + const state = modes.getWindowModeState(2003); 89 + const editCount = state.minors.filter(m => m === 'edit').length; 90 + assert.strictEqual(editCount, 1); 91 + }); 92 + }); 93 + 94 + describe('disableMinorMode', () => { 95 + it('should disable a minor mode', () => { 96 + modes.setMajorMode(3001, 'page'); 97 + modes.enableMinorMode(3001, 'edit'); 98 + modes.disableMinorMode(3001, 'edit'); 99 + 100 + const state = modes.getWindowModeState(3001); 101 + assert.ok(!state.minors.includes('edit')); 102 + }); 103 + 104 + it('should only remove specified minor mode', () => { 105 + modes.setMajorMode(3002, 'page'); 106 + modes.enableMinorMode(3002, 'preview'); 107 + modes.enableMinorMode(3002, 'edit'); 108 + modes.disableMinorMode(3002, 'edit'); 109 + 110 + const state = modes.getWindowModeState(3002); 111 + assert.ok(state.minors.includes('preview')); 112 + assert.ok(!state.minors.includes('edit')); 113 + }); 114 + 115 + it('should handle disabling non-existent minor mode gracefully', () => { 116 + modes.setMajorMode(3003, 'page'); 117 + modes.disableMinorMode(3003, 'search'); // Never enabled 118 + 119 + const state = modes.getWindowModeState(3003); 120 + assert.deepStrictEqual(state.minors, []); 121 + }); 122 + }); 123 + 124 + describe('toggleMinorMode', () => { 125 + it('should enable when disabled', () => { 126 + modes.setMajorMode(4001, 'page'); 127 + const enabled = modes.toggleMinorMode(4001, 'edit'); 128 + 129 + assert.strictEqual(enabled, true); 130 + const state = modes.getWindowModeState(4001); 131 + assert.ok(state.minors.includes('edit')); 132 + }); 133 + 134 + it('should disable when enabled', () => { 135 + modes.setMajorMode(4002, 'page'); 136 + modes.enableMinorMode(4002, 'edit'); 137 + const enabled = modes.toggleMinorMode(4002, 'edit'); 138 + 139 + assert.strictEqual(enabled, false); 140 + const state = modes.getWindowModeState(4002); 141 + assert.ok(!state.minors.includes('edit')); 142 + }); 143 + }); 144 + 145 + describe('cleanupWindowMode', () => { 146 + it('should remove mode state for a window', () => { 147 + modes.setMajorMode(5001, 'settings'); 148 + modes.enableMinorMode(5001, 'edit'); 149 + modes.cleanupWindowMode(5001); 150 + 151 + // After cleanup, should get default state 152 + const state = modes.getWindowModeState(5001); 153 + assert.strictEqual(state.major, 'default'); 154 + assert.deepStrictEqual(state.minors, []); 155 + }); 156 + }); 157 + 158 + describe('getAllModes', () => { 159 + it('should return all major and minor modes', () => { 160 + const allModes = modes.getAllModes(); 161 + 162 + // Check major modes exist 163 + const majorModes = allModes.filter(m => m.type === 'major'); 164 + const majorIds = majorModes.map(m => m.id); 165 + assert.ok(majorIds.includes('default')); 166 + assert.ok(majorIds.includes('page')); 167 + assert.ok(majorIds.includes('group')); 168 + assert.ok(majorIds.includes('settings')); 169 + 170 + // Check minor modes exist 171 + const minorModes = allModes.filter(m => m.type === 'minor'); 172 + const minorIds = minorModes.map(m => m.id); 173 + assert.ok(minorIds.includes('preview')); 174 + assert.ok(minorIds.includes('edit')); 175 + assert.ok(minorIds.includes('annotate')); 176 + assert.ok(minorIds.includes('search')); 177 + }); 178 + 179 + it('should include names for all modes', () => { 180 + const allModes = modes.getAllModes(); 181 + for (const mode of allModes) { 182 + assert.ok(mode.name, `Mode ${mode.id} should have a name`); 183 + assert.ok(typeof mode.name === 'string'); 184 + } 185 + }); 186 + }); 187 + 188 + describe('isInMajorMode', () => { 189 + it('should return true when in the specified mode', () => { 190 + modes.setMajorMode(6001, 'page'); 191 + assert.strictEqual(modes.isInMajorMode(6001, 'page'), true); 192 + }); 193 + 194 + it('should return false when in a different mode', () => { 195 + modes.setMajorMode(6002, 'group'); 196 + assert.strictEqual(modes.isInMajorMode(6002, 'page'), false); 197 + }); 198 + 199 + it('should return false for unknown window', () => { 200 + // Fresh window ID that was never set 201 + assert.strictEqual(modes.isInMajorMode(99999, 'page'), false); 202 + }); 203 + }); 204 + 205 + describe('hasMinorMode', () => { 206 + it('should return true when minor mode is active', () => { 207 + modes.setMajorMode(7001, 'page'); 208 + modes.enableMinorMode(7001, 'edit'); 209 + assert.strictEqual(modes.hasMinorMode(7001, 'edit'), true); 210 + }); 211 + 212 + it('should return false when minor mode is not active', () => { 213 + modes.setMajorMode(7002, 'page'); 214 + assert.strictEqual(modes.hasMinorMode(7002, 'search'), false); 215 + }); 216 + }); 217 + 218 + describe('detectModeFromUrl', () => { 219 + it('should detect settings mode from settings URL', () => { 220 + assert.strictEqual(modes.detectModeFromUrl('peek://app/settings/settings.html'), 'settings'); 221 + assert.strictEqual(modes.detectModeFromUrl('peek://ext/cmd/settings.html'), 'settings'); 222 + }); 223 + 224 + it('should detect group mode from groups URL', () => { 225 + assert.strictEqual(modes.detectModeFromUrl('peek://ext/groups/index.html'), 'group'); 226 + assert.strictEqual(modes.detectModeFromUrl('peek://groups/groups.html'), 'group'); 227 + }); 228 + 229 + it('should detect page mode from http URLs', () => { 230 + assert.strictEqual(modes.detectModeFromUrl('https://example.com'), 'page'); 231 + assert.strictEqual(modes.detectModeFromUrl('http://localhost:3000'), 'page'); 232 + }); 233 + 234 + it('should return default for other URLs', () => { 235 + assert.strictEqual(modes.detectModeFromUrl('peek://app/background.html'), 'default'); 236 + assert.strictEqual(modes.detectModeFromUrl('peek://ext/cmd/panel.html'), 'default'); 237 + }); 238 + 239 + it('should return default for empty URL', () => { 240 + assert.strictEqual(modes.detectModeFromUrl(''), 'default'); 241 + }); 242 + }); 243 + 244 + describe('checkModeConditions', () => { 245 + beforeEach(() => { 246 + // Set up a known state 247 + modes.setMajorMode(8001, 'page'); 248 + modes.enableMinorMode(8001, 'edit'); 249 + modes.enableMinorMode(8001, 'search'); 250 + }); 251 + 252 + it('should return true when major mode matches', () => { 253 + assert.strictEqual(modes.checkModeConditions(8001, 'page'), true); 254 + }); 255 + 256 + it('should return false when major mode does not match', () => { 257 + assert.strictEqual(modes.checkModeConditions(8001, 'group'), false); 258 + }); 259 + 260 + it('should return true when all required minor modes are active', () => { 261 + assert.strictEqual(modes.checkModeConditions(8001, undefined, ['edit']), true); 262 + assert.strictEqual(modes.checkModeConditions(8001, undefined, ['edit', 'search']), true); 263 + }); 264 + 265 + it('should return false when required minor modes are not all active', () => { 266 + assert.strictEqual(modes.checkModeConditions(8001, undefined, ['preview']), false); 267 + assert.strictEqual(modes.checkModeConditions(8001, undefined, ['edit', 'preview']), false); 268 + }); 269 + 270 + it('should check both major and minor conditions', () => { 271 + assert.strictEqual(modes.checkModeConditions(8001, 'page', ['edit']), true); 272 + assert.strictEqual(modes.checkModeConditions(8001, 'group', ['edit']), false); 273 + assert.strictEqual(modes.checkModeConditions(8001, 'page', ['preview']), false); 274 + }); 275 + 276 + it('should return true when no conditions specified', () => { 277 + assert.strictEqual(modes.checkModeConditions(8001), true); 278 + assert.strictEqual(modes.checkModeConditions(8001, undefined, []), true); 279 + }); 280 + 281 + it('should handle unknown window (default mode)', () => { 282 + // Unknown window = default mode, no minors 283 + assert.strictEqual(modes.checkModeConditions(88888, 'default'), true); 284 + assert.strictEqual(modes.checkModeConditions(88888, 'page'), false); 285 + }); 286 + }); 287 + 288 + describe('buildCommandContext', () => { 289 + it('should return context with mode for known window', () => { 290 + modes.setMajorMode(9001, 'settings'); 291 + modes.enableMinorMode(9001, 'edit'); 292 + 293 + // Note: buildCommandContext uses BrowserWindow.fromId which won't work in unit tests 294 + // This test would need to be an integration test or we'd need to mock BrowserWindow 295 + // For now, test with null windowId 296 + const context = modes.buildCommandContext(null); 297 + assert.strictEqual(context.windowId, null); 298 + assert.strictEqual(context.mode, null); 299 + }); 300 + 301 + it('should return null mode for null windowId', () => { 302 + const context = modes.buildCommandContext(null); 303 + assert.strictEqual(context.windowId, null); 304 + assert.strictEqual(context.mode, null); 305 + assert.strictEqual(context.url, null); 306 + assert.strictEqual(context.title, null); 307 + }); 308 + }); 309 + });
+41 -11
backend/electron/modes.ts
··· 23 23 * 4. Scope-aware command targeting 24 24 */ 25 25 26 - import { BrowserWindow } from 'electron'; 27 - import { publish, scopes as PubSubScopes, getSystemAddress } from './pubsub.js'; 28 26 import { DEBUG } from './config.js'; 27 + 28 + // Lazy-load Electron modules to allow testing without Electron 29 + let BrowserWindow: typeof import('electron').BrowserWindow | null = null; 30 + let publish: typeof import('./pubsub.js').publish | null = null; 31 + let PubSubScopes: typeof import('./pubsub.js').scopes | null = null; 32 + let getSystemAddress: typeof import('./pubsub.js').getSystemAddress | null = null; 33 + 34 + async function loadElectronModules(): Promise<void> { 35 + try { 36 + const electron = await import('electron'); 37 + BrowserWindow = electron.BrowserWindow; 38 + const pubsub = await import('./pubsub.js'); 39 + publish = pubsub.publish; 40 + PubSubScopes = pubsub.scopes; 41 + getSystemAddress = pubsub.getSystemAddress; 42 + } catch { 43 + // Electron not available (e.g., in unit tests) 44 + DEBUG && console.log('[modes] Running without Electron (test mode)'); 45 + } 46 + } 47 + 48 + // Initialize asynchronously 49 + loadElectronModules(); 29 50 30 51 // ============================================================================ 31 52 // Types ··· 228 249 * Publish mode change event via pubsub 229 250 */ 230 251 function publishModeChange(windowId: number, state: WindowModeState): void { 231 - publish(getSystemAddress(), PubSubScopes.GLOBAL, 'modes:changed', { 232 - windowId, 233 - major: state.major, 234 - minors: [...state.minors], 235 - }); 252 + // Only publish if pubsub is available (not in test mode) 253 + if (publish && PubSubScopes && getSystemAddress) { 254 + publish(getSystemAddress(), PubSubScopes.GLOBAL, 'modes:changed', { 255 + windowId, 256 + major: state.major, 257 + minors: [...state.minors], 258 + }); 259 + } 236 260 } 237 261 238 262 // ============================================================================ ··· 321 345 }; 322 346 323 347 if (targetWindowId !== null) { 324 - const win = BrowserWindow.fromId(targetWindowId); 325 - if (win && !win.isDestroyed()) { 348 + // BrowserWindow may not be available in test mode 349 + if (BrowserWindow) { 350 + const win = BrowserWindow.fromId(targetWindowId); 351 + if (win && !win.isDestroyed()) { 352 + context.mode = getWindowModeState(targetWindowId); 353 + context.url = win.webContents.getURL(); 354 + context.title = win.getTitle(); 355 + } 356 + } else { 357 + // Test mode - just get mode state without window info 326 358 context.mode = getWindowModeState(targetWindowId); 327 - context.url = win.webContents.getURL(); 328 - context.title = win.getTitle(); 329 359 } 330 360 } 331 361
+371
backend/electron/shortcuts.test.ts
··· 1 + /** 2 + * Unit tests for Shortcuts module 3 + * Tests shortcut parsing, registration, and mode-conditional behavior 4 + */ 5 + 6 + import { describe, it, before, beforeEach, afterEach } from 'node:test'; 7 + import * as assert from 'node:assert'; 8 + import type { InputEvent } from './shortcuts.js'; 9 + 10 + // Import will be done after ensuring module is compiled 11 + let shortcuts: typeof import('./shortcuts.js'); 12 + let modes: typeof import('./modes.js'); 13 + 14 + describe('Shortcuts Module Tests', () => { 15 + before(async () => { 16 + // Dynamic import of the compiled modules 17 + shortcuts = await import('./shortcuts.js'); 18 + modes = await import('./modes.js'); 19 + }); 20 + 21 + describe('parseShortcut', () => { 22 + it('should parse single key', () => { 23 + const parsed = shortcuts.parseShortcut('a'); 24 + assert.strictEqual(parsed.code, 'KeyA'); 25 + assert.strictEqual(parsed.ctrl, false); 26 + assert.strictEqual(parsed.alt, false); 27 + assert.strictEqual(parsed.shift, false); 28 + assert.strictEqual(parsed.meta, false); 29 + }); 30 + 31 + it('should parse Ctrl modifier', () => { 32 + const parsed = shortcuts.parseShortcut('Ctrl+A'); 33 + assert.strictEqual(parsed.code, 'KeyA'); 34 + assert.strictEqual(parsed.ctrl, true); 35 + }); 36 + 37 + it('should parse Alt modifier', () => { 38 + const parsed = shortcuts.parseShortcut('Alt+Q'); 39 + assert.strictEqual(parsed.code, 'KeyQ'); 40 + assert.strictEqual(parsed.alt, true); 41 + }); 42 + 43 + it('should parse Shift modifier', () => { 44 + const parsed = shortcuts.parseShortcut('Shift+Enter'); 45 + assert.strictEqual(parsed.code, 'Enter'); 46 + assert.strictEqual(parsed.shift, true); 47 + }); 48 + 49 + it('should parse multiple modifiers', () => { 50 + const parsed = shortcuts.parseShortcut('Ctrl+Shift+Alt+P'); 51 + assert.strictEqual(parsed.code, 'KeyP'); 52 + assert.strictEqual(parsed.ctrl, true); 53 + assert.strictEqual(parsed.alt, true); 54 + assert.strictEqual(parsed.shift, true); 55 + }); 56 + 57 + it('should parse special keys', () => { 58 + assert.strictEqual(shortcuts.parseShortcut('Enter').code, 'Enter'); 59 + assert.strictEqual(shortcuts.parseShortcut('Tab').code, 'Tab'); 60 + assert.strictEqual(shortcuts.parseShortcut('Escape').code, 'Escape'); 61 + assert.strictEqual(shortcuts.parseShortcut('Space').code, 'Space'); 62 + assert.strictEqual(shortcuts.parseShortcut('Backspace').code, 'Backspace'); 63 + }); 64 + 65 + it('should parse arrow keys', () => { 66 + assert.strictEqual(shortcuts.parseShortcut('ArrowUp').code, 'ArrowUp'); 67 + assert.strictEqual(shortcuts.parseShortcut('ArrowDown').code, 'ArrowDown'); 68 + assert.strictEqual(shortcuts.parseShortcut('Up').code, 'ArrowUp'); 69 + assert.strictEqual(shortcuts.parseShortcut('Down').code, 'ArrowDown'); 70 + }); 71 + 72 + it('should parse function keys', () => { 73 + assert.strictEqual(shortcuts.parseShortcut('F1').code, 'F1'); 74 + assert.strictEqual(shortcuts.parseShortcut('F12').code, 'F12'); 75 + }); 76 + 77 + it('should parse number keys', () => { 78 + const parsed = shortcuts.parseShortcut('Alt+1'); 79 + assert.strictEqual(parsed.code, 'Digit1'); 80 + assert.strictEqual(parsed.alt, true); 81 + }); 82 + 83 + it('should be case-insensitive', () => { 84 + const parsed1 = shortcuts.parseShortcut('CTRL+A'); 85 + const parsed2 = shortcuts.parseShortcut('ctrl+a'); 86 + assert.strictEqual(parsed1.ctrl, parsed2.ctrl); 87 + assert.strictEqual(parsed1.code, parsed2.code); 88 + }); 89 + }); 90 + 91 + describe('inputMatchesShortcut', () => { 92 + it('should match simple key', () => { 93 + const parsed = shortcuts.parseShortcut('a'); 94 + const input: InputEvent = { 95 + type: 'keyDown', 96 + alt: false, 97 + shift: false, 98 + meta: false, 99 + control: false, 100 + code: 'KeyA' 101 + }; 102 + assert.strictEqual(shortcuts.inputMatchesShortcut(input, parsed), true); 103 + }); 104 + 105 + it('should not match with wrong modifiers', () => { 106 + const parsed = shortcuts.parseShortcut('Ctrl+A'); 107 + const input: InputEvent = { 108 + type: 'keyDown', 109 + alt: false, 110 + shift: false, 111 + meta: false, 112 + control: false, // Missing ctrl! 113 + code: 'KeyA' 114 + }; 115 + assert.strictEqual(shortcuts.inputMatchesShortcut(input, parsed), false); 116 + }); 117 + 118 + it('should match with correct modifiers', () => { 119 + const parsed = shortcuts.parseShortcut('Ctrl+Shift+P'); 120 + const input: InputEvent = { 121 + type: 'keyDown', 122 + alt: false, 123 + shift: true, 124 + meta: false, 125 + control: true, 126 + code: 'KeyP' 127 + }; 128 + assert.strictEqual(shortcuts.inputMatchesShortcut(input, parsed), true); 129 + }); 130 + 131 + it('should not match with extra modifiers', () => { 132 + const parsed = shortcuts.parseShortcut('Ctrl+A'); 133 + const input: InputEvent = { 134 + type: 'keyDown', 135 + alt: true, // Extra alt! 136 + shift: false, 137 + meta: false, 138 + control: true, 139 + code: 'KeyA' 140 + }; 141 + assert.strictEqual(shortcuts.inputMatchesShortcut(input, parsed), false); 142 + }); 143 + }); 144 + 145 + describe('registerLocalShortcut and handleLocalShortcut', () => { 146 + let callCount: number; 147 + 148 + beforeEach(() => { 149 + callCount = 0; 150 + }); 151 + 152 + it('should register and trigger a shortcut', () => { 153 + shortcuts.registerLocalShortcut('Ctrl+T', 'test-source-1', () => { 154 + callCount++; 155 + }); 156 + 157 + const input: InputEvent = { 158 + type: 'keyDown', 159 + alt: false, 160 + shift: false, 161 + meta: false, 162 + control: true, 163 + code: 'KeyT' 164 + }; 165 + 166 + const handled = shortcuts.handleLocalShortcut(input); 167 + assert.strictEqual(handled, true); 168 + assert.strictEqual(callCount, 1); 169 + 170 + // Cleanup 171 + shortcuts.unregisterLocalShortcut('Ctrl+T', 'test-source-1'); 172 + }); 173 + 174 + it('should not trigger on keyUp', () => { 175 + shortcuts.registerLocalShortcut('Ctrl+U', 'test-source-2', () => { 176 + callCount++; 177 + }); 178 + 179 + const input: InputEvent = { 180 + type: 'keyUp', // Not keyDown! 181 + alt: false, 182 + shift: false, 183 + meta: false, 184 + control: true, 185 + code: 'KeyU' 186 + }; 187 + 188 + const handled = shortcuts.handleLocalShortcut(input); 189 + assert.strictEqual(handled, false); 190 + assert.strictEqual(callCount, 0); 191 + 192 + // Cleanup 193 + shortcuts.unregisterLocalShortcut('Ctrl+U', 'test-source-2'); 194 + }); 195 + 196 + it('should return false for unregistered shortcut', () => { 197 + const input: InputEvent = { 198 + type: 'keyDown', 199 + alt: false, 200 + shift: false, 201 + meta: false, 202 + control: true, 203 + code: 'KeyZ' // Not registered 204 + }; 205 + 206 + const handled = shortcuts.handleLocalShortcut(input); 207 + assert.strictEqual(handled, false); 208 + }); 209 + }); 210 + 211 + describe('Mode-conditional shortcuts', () => { 212 + let pageCallCount: number; 213 + let groupCallCount: number; 214 + let defaultCallCount: number; 215 + 216 + beforeEach(() => { 217 + pageCallCount = 0; 218 + groupCallCount = 0; 219 + defaultCallCount = 0; 220 + 221 + // Set up test window mode 222 + modes.setMajorMode(10001, 'page'); 223 + }); 224 + 225 + afterEach(() => { 226 + // Cleanup shortcuts 227 + shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-page', { majorMode: 'page' }); 228 + shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-group', { majorMode: 'group' }); 229 + shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-default'); 230 + modes.cleanupWindowMode(10001); 231 + }); 232 + 233 + it('should trigger mode-conditional shortcut when mode matches', () => { 234 + shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-page', () => { 235 + pageCallCount++; 236 + }, { majorMode: 'page' }); 237 + 238 + const input: InputEvent = { 239 + type: 'keyDown', 240 + alt: false, 241 + shift: false, 242 + meta: false, 243 + control: true, 244 + code: 'KeyM' 245 + }; 246 + 247 + // Window 10001 is in 'page' mode 248 + const handled = shortcuts.handleLocalShortcut(input, 10001); 249 + assert.strictEqual(handled, true); 250 + assert.strictEqual(pageCallCount, 1); 251 + }); 252 + 253 + it('should not trigger mode-conditional shortcut when mode does not match', () => { 254 + shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-group', () => { 255 + groupCallCount++; 256 + }, { majorMode: 'group' }); 257 + 258 + const input: InputEvent = { 259 + type: 'keyDown', 260 + alt: false, 261 + shift: false, 262 + meta: false, 263 + control: true, 264 + code: 'KeyM' 265 + }; 266 + 267 + // Window 10001 is in 'page' mode, not 'group' 268 + const handled = shortcuts.handleLocalShortcut(input, 10001); 269 + assert.strictEqual(handled, false); 270 + assert.strictEqual(groupCallCount, 0); 271 + }); 272 + 273 + it('should allow same key with different mode conditions', () => { 274 + // Register same key for different modes 275 + shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-page', () => { 276 + pageCallCount++; 277 + }, { majorMode: 'page' }); 278 + 279 + shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-group', () => { 280 + groupCallCount++; 281 + }, { majorMode: 'group' }); 282 + 283 + const input: InputEvent = { 284 + type: 'keyDown', 285 + alt: false, 286 + shift: false, 287 + meta: false, 288 + control: true, 289 + code: 'KeyM' 290 + }; 291 + 292 + // In page mode - should trigger page handler 293 + modes.setMajorMode(10001, 'page'); 294 + let handled = shortcuts.handleLocalShortcut(input, 10001); 295 + assert.strictEqual(handled, true); 296 + assert.strictEqual(pageCallCount, 1); 297 + assert.strictEqual(groupCallCount, 0); 298 + 299 + // Switch to group mode - should trigger group handler 300 + modes.setMajorMode(10001, 'group'); 301 + handled = shortcuts.handleLocalShortcut(input, 10001); 302 + assert.strictEqual(handled, true); 303 + assert.strictEqual(pageCallCount, 1); // No change 304 + assert.strictEqual(groupCallCount, 1); 305 + }); 306 + 307 + it('should fall back to non-conditional shortcut when no mode match', () => { 308 + // Register mode-conditional for 'group' 309 + shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-group', () => { 310 + groupCallCount++; 311 + }, { majorMode: 'group' }); 312 + 313 + // Register non-conditional fallback 314 + shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-default', () => { 315 + defaultCallCount++; 316 + }); 317 + 318 + const input: InputEvent = { 319 + type: 'keyDown', 320 + alt: false, 321 + shift: false, 322 + meta: false, 323 + control: true, 324 + code: 'KeyM' 325 + }; 326 + 327 + // In page mode - group doesn't match, should trigger default 328 + modes.setMajorMode(10001, 'page'); 329 + const handled = shortcuts.handleLocalShortcut(input, 10001); 330 + assert.strictEqual(handled, true); 331 + assert.strictEqual(groupCallCount, 0); 332 + assert.strictEqual(defaultCallCount, 1); 333 + }); 334 + }); 335 + 336 + describe('unregisterShortcutsForAddress', () => { 337 + it('should unregister all shortcuts for an address', () => { 338 + let callCount = 0; 339 + const testSource = 'test-cleanup-source'; 340 + 341 + shortcuts.registerLocalShortcut('Ctrl+1', testSource, () => callCount++); 342 + shortcuts.registerLocalShortcut('Ctrl+2', testSource, () => callCount++); 343 + shortcuts.registerLocalShortcut('Ctrl+3', 'other-source', () => callCount++); 344 + 345 + // Unregister all for testSource 346 + shortcuts.unregisterShortcutsForAddress(testSource); 347 + 348 + // Ctrl+1 and Ctrl+2 should not work 349 + const input1: InputEvent = { 350 + type: 'keyDown', alt: false, shift: false, meta: false, control: true, code: 'Digit1' 351 + }; 352 + const input2: InputEvent = { 353 + type: 'keyDown', alt: false, shift: false, meta: false, control: true, code: 'Digit2' 354 + }; 355 + const input3: InputEvent = { 356 + type: 'keyDown', alt: false, shift: false, meta: false, control: true, code: 'Digit3' 357 + }; 358 + 359 + shortcuts.handleLocalShortcut(input1); 360 + shortcuts.handleLocalShortcut(input2); 361 + assert.strictEqual(callCount, 0); 362 + 363 + // Ctrl+3 from other-source should still work 364 + shortcuts.handleLocalShortcut(input3); 365 + assert.strictEqual(callCount, 1); 366 + 367 + // Cleanup 368 + shortcuts.unregisterShortcutsForAddress('other-source'); 369 + }); 370 + }); 371 + });
+29 -2
backend/electron/shortcuts.ts
··· 8 8 * - Shortcut parsing and matching 9 9 */ 10 10 11 - import { globalShortcut, BrowserWindow } from 'electron'; 12 11 import { DEBUG } from './config.js'; 13 12 import { checkModeConditions, type MajorModeId, type MinorModeId } from './modes.js'; 13 + 14 + // Lazy-load Electron modules to allow testing without Electron 15 + let globalShortcut: typeof import('electron').globalShortcut | null = null; 16 + let BrowserWindow: typeof import('electron').BrowserWindow | null = null; 17 + 18 + try { 19 + const electron = await import('electron'); 20 + globalShortcut = electron.globalShortcut; 21 + BrowserWindow = electron.BrowserWindow; 22 + } catch { 23 + // Electron not available (e.g., in unit tests) 24 + DEBUG && console.log('[shortcuts] Running without Electron (test mode)'); 25 + } 14 26 15 27 // Maps for tracking shortcuts 16 28 // Global shortcuts: shortcut string -> source address ··· 147 159 ): Error | undefined { 148 160 DEBUG && console.log('registerGlobalShortcut', shortcut); 149 161 162 + // globalShortcut not available in test mode 163 + if (!globalShortcut) { 164 + globalShortcuts.set(shortcut, source); 165 + return undefined; 166 + } 167 + 150 168 if (globalShortcut.isRegistered(shortcut)) { 151 169 console.error('Shortcut already registered, unregistering first:', shortcut); 152 170 globalShortcut.unregister(shortcut); ··· 172 190 export function unregisterGlobalShortcut(shortcut: string): Error | undefined { 173 191 DEBUG && console.log('unregisterGlobalShortcut', shortcut); 174 192 193 + // globalShortcut not available in test mode 194 + if (!globalShortcut) { 195 + globalShortcuts.delete(shortcut); 196 + return undefined; 197 + } 198 + 175 199 if (!globalShortcut.isRegistered(shortcut)) { 176 200 console.error('Unable to unregister shortcut because not registered:', shortcut); 177 201 return new Error(`Shortcut not registered: ${shortcut}`); ··· 269 293 // Check mode conditions if specified 270 294 if (entry.modeConditions?.majorMode || entry.modeConditions?.minorModes?.length) { 271 295 // Need a window ID to check mode 272 - if (focusedWindowId === undefined) { 296 + if (focusedWindowId === undefined && BrowserWindow) { 273 297 // Try to get focused window 274 298 const focused = BrowserWindow.getFocusedWindow(); 275 299 focusedWindowId = focused?.id; ··· 338 362 * Check if a global shortcut is registered 339 363 */ 340 364 export function isGlobalShortcutRegistered(shortcut: string): boolean { 365 + if (!globalShortcut) { 366 + return globalShortcuts.has(shortcut); 367 + } 341 368 return globalShortcut.isRegistered(shortcut); 342 369 }
+1 -1
backend/electron/sync.ts
··· 597 597 } 598 598 599 599 if (item.deletedAt > 0) { 600 - body.deletedAt = item.deletedAt; 600 + body.deleted_at = item.deletedAt; 601 601 } 602 602 603 603 // POST to server with profile parameter
+4
package.json
··· 107 107 "test:packaged": "yarn kill:packaged; HEADLESS=1 PACKAGED=1 npx playwright test tests/desktop/", 108 108 "test:packaged:debug": "yarn kill:packaged; HEADLESS=1 PACKAGED=1 DEBUG=1 npx playwright test tests/desktop/", 109 109 "//-- Testing --//": "", 110 + "test:unit": "./scripts/timed.sh sh -c 'yarn build && node --test dist/backend/electron/*.test.js'", 111 + "test:unit:modes": "./scripts/timed.sh sh -c 'yarn build && node --test dist/backend/electron/modes.test.js'", 112 + "test:unit:shortcuts": "./scripts/timed.sh sh -c 'yarn build && node --test dist/backend/electron/shortcuts.test.js'", 113 + "test:unit:datastore": "./scripts/timed.sh sh -c 'yarn build && node --test dist/backend/electron/datastore.test.js'", 110 114 "test": "./scripts/timed.sh sh -c 'yarn build && yarn test:electron && yarn test:tauri'", 111 115 "test:electron": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=1 BACKEND=electron npx playwright test tests/desktop/'", 112 116 "test:electron:x": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=1 BACKEND=electron npx playwright test tests/desktop/ -x'",