markupr 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/.claude/commands/review-feedback.md +47 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/CODEOWNERS +16 -0
  4. package/.github/FUNDING.yml +1 -0
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
  8. package/.github/dependabot.yml +70 -0
  9. package/.github/workflows/ci.yml +184 -0
  10. package/.github/workflows/deploy-landing.yml +134 -0
  11. package/.github/workflows/nightly.yml +288 -0
  12. package/.github/workflows/release.yml +318 -0
  13. package/CHANGELOG.md +127 -0
  14. package/CLAUDE.md +137 -0
  15. package/CODE_OF_CONDUCT.md +9 -0
  16. package/CONTRIBUTING.md +390 -0
  17. package/LICENSE +21 -0
  18. package/PRODUCT_VISION.md +277 -0
  19. package/README.md +517 -0
  20. package/SECURITY.md +51 -0
  21. package/SIGNING_INSTRUCTIONS.md +284 -0
  22. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
  23. package/assets/svg-source/dmg-background.svg +70 -0
  24. package/assets/svg-source/icon.svg +20 -0
  25. package/assets/svg-source/tray-icon-processing.svg +7 -0
  26. package/assets/svg-source/tray-icon-recording.svg +7 -0
  27. package/assets/svg-source/tray-icon.svg +6 -0
  28. package/assets/tray-complete.png +0 -0
  29. package/assets/tray-complete@2x.png +0 -0
  30. package/assets/tray-completeTemplate.png +0 -0
  31. package/assets/tray-completeTemplate@2x.png +0 -0
  32. package/assets/tray-error.png +0 -0
  33. package/assets/tray-error@2x.png +0 -0
  34. package/assets/tray-errorTemplate.png +0 -0
  35. package/assets/tray-errorTemplate@2x.png +0 -0
  36. package/assets/tray-icon-processing.png +0 -0
  37. package/assets/tray-icon-processing@2x.png +0 -0
  38. package/assets/tray-icon-processingTemplate.png +0 -0
  39. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  40. package/assets/tray-icon-recording.png +0 -0
  41. package/assets/tray-icon-recording@2x.png +0 -0
  42. package/assets/tray-icon-recordingTemplate.png +0 -0
  43. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  44. package/assets/tray-icon.png +0 -0
  45. package/assets/tray-icon@2x.png +0 -0
  46. package/assets/tray-iconTemplate.png +0 -0
  47. package/assets/tray-iconTemplate@2x.png +0 -0
  48. package/assets/tray-idle.png +0 -0
  49. package/assets/tray-idle@2x.png +0 -0
  50. package/assets/tray-idleTemplate.png +0 -0
  51. package/assets/tray-idleTemplate@2x.png +0 -0
  52. package/assets/tray-processing-0.png +0 -0
  53. package/assets/tray-processing-0@2x.png +0 -0
  54. package/assets/tray-processing-0Template.png +0 -0
  55. package/assets/tray-processing-0Template@2x.png +0 -0
  56. package/assets/tray-processing-1.png +0 -0
  57. package/assets/tray-processing-1@2x.png +0 -0
  58. package/assets/tray-processing-1Template.png +0 -0
  59. package/assets/tray-processing-1Template@2x.png +0 -0
  60. package/assets/tray-processing-2.png +0 -0
  61. package/assets/tray-processing-2@2x.png +0 -0
  62. package/assets/tray-processing-2Template.png +0 -0
  63. package/assets/tray-processing-2Template@2x.png +0 -0
  64. package/assets/tray-processing-3.png +0 -0
  65. package/assets/tray-processing-3@2x.png +0 -0
  66. package/assets/tray-processing-3Template.png +0 -0
  67. package/assets/tray-processing-3Template@2x.png +0 -0
  68. package/assets/tray-processing.png +0 -0
  69. package/assets/tray-processing@2x.png +0 -0
  70. package/assets/tray-processingTemplate.png +0 -0
  71. package/assets/tray-processingTemplate@2x.png +0 -0
  72. package/assets/tray-recording.png +0 -0
  73. package/assets/tray-recording@2x.png +0 -0
  74. package/assets/tray-recordingTemplate.png +0 -0
  75. package/assets/tray-recordingTemplate@2x.png +0 -0
  76. package/build/DMG_BACKGROUND_SPEC.md +50 -0
  77. package/build/dmg-background.png +0 -0
  78. package/build/dmg-background@2x.png +0 -0
  79. package/build/entitlements.mac.inherit.plist +27 -0
  80. package/build/entitlements.mac.plist +41 -0
  81. package/build/favicon-16.png +0 -0
  82. package/build/favicon-180.png +0 -0
  83. package/build/favicon-192.png +0 -0
  84. package/build/favicon-32.png +0 -0
  85. package/build/favicon-48.png +0 -0
  86. package/build/favicon-512.png +0 -0
  87. package/build/favicon-64.png +0 -0
  88. package/build/icon-128.png +0 -0
  89. package/build/icon-16.png +0 -0
  90. package/build/icon-24.png +0 -0
  91. package/build/icon-256.png +0 -0
  92. package/build/icon-32.png +0 -0
  93. package/build/icon-48.png +0 -0
  94. package/build/icon-64.png +0 -0
  95. package/build/icon.icns +0 -0
  96. package/build/icon.ico +0 -0
  97. package/build/icon.iconset/icon_128x128.png +0 -0
  98. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  99. package/build/icon.iconset/icon_16x16.png +0 -0
  100. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  101. package/build/icon.iconset/icon_256x256.png +0 -0
  102. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  103. package/build/icon.iconset/icon_32x32.png +0 -0
  104. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  105. package/build/icon.iconset/icon_512x512.png +0 -0
  106. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  107. package/build/icon.png +0 -0
  108. package/build/installer-header.bmp +0 -0
  109. package/build/installer-header.png +0 -0
  110. package/build/installer-sidebar.bmp +0 -0
  111. package/build/installer-sidebar.png +0 -0
  112. package/build/installer.nsh +45 -0
  113. package/build/overlay-processing.png +0 -0
  114. package/build/overlay-recording.png +0 -0
  115. package/build/toolbar-record.png +0 -0
  116. package/build/toolbar-screenshot.png +0 -0
  117. package/build/toolbar-settings.png +0 -0
  118. package/build/toolbar-stop.png +0 -0
  119. package/dist/main/index.mjs +12612 -0
  120. package/dist/preload/index.mjs +907 -0
  121. package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
  122. package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
  123. package/dist/renderer/index.html +27 -0
  124. package/docs/AI_AGENT_QUICKSTART.md +42 -0
  125. package/docs/AI_PIPELINE_DESIGN.md +595 -0
  126. package/docs/API.md +514 -0
  127. package/docs/ARCHITECTURE.md +460 -0
  128. package/docs/CONFIGURATION.md +336 -0
  129. package/docs/DEVELOPMENT.md +508 -0
  130. package/docs/EXPORT_FORMATS.md +451 -0
  131. package/docs/GETTING_STARTED.md +236 -0
  132. package/docs/KEYBOARD_SHORTCUTS.md +334 -0
  133. package/docs/TROUBLESHOOTING.md +418 -0
  134. package/docs/landing/index.html +672 -0
  135. package/docs/landing/script.js +342 -0
  136. package/docs/landing/styles.css +1543 -0
  137. package/electron-builder.yml +140 -0
  138. package/electron.vite.config.ts +63 -0
  139. package/package.json +108 -0
  140. package/railway.json +12 -0
  141. package/scripts/build.mjs +51 -0
  142. package/scripts/generate-icons.mjs +314 -0
  143. package/scripts/generate-installer-images.cjs +253 -0
  144. package/scripts/generate-tray-icons.mjs +258 -0
  145. package/scripts/notarize.cjs +180 -0
  146. package/scripts/one-click-clean-test.sh +147 -0
  147. package/scripts/postinstall.mjs +36 -0
  148. package/scripts/setup-markupr.sh +55 -0
  149. package/setup +17 -0
  150. package/site/index.html +1835 -0
  151. package/site/package.json +11 -0
  152. package/site/railway.json +12 -0
  153. package/site/server.js +31 -0
  154. package/src/main/AutoUpdater.ts +392 -0
  155. package/src/main/CrashRecovery.ts +655 -0
  156. package/src/main/ErrorHandler.ts +703 -0
  157. package/src/main/HotkeyManager.ts +399 -0
  158. package/src/main/MenuManager.ts +529 -0
  159. package/src/main/PermissionManager.ts +420 -0
  160. package/src/main/SessionController.ts +1465 -0
  161. package/src/main/TrayManager.ts +540 -0
  162. package/src/main/ai/AIPipelineManager.ts +199 -0
  163. package/src/main/ai/ClaudeAnalyzer.ts +339 -0
  164. package/src/main/ai/ImageOptimizer.ts +176 -0
  165. package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
  166. package/src/main/ai/index.ts +16 -0
  167. package/src/main/ai/types.ts +258 -0
  168. package/src/main/analysis/ClarificationGenerator.ts +385 -0
  169. package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
  170. package/src/main/analysis/index.ts +19 -0
  171. package/src/main/audio/AudioCapture.ts +978 -0
  172. package/src/main/audio/audioUtils.ts +100 -0
  173. package/src/main/audio/index.ts +20 -0
  174. package/src/main/capture/index.ts +1 -0
  175. package/src/main/index.ts +1693 -0
  176. package/src/main/ipc/captureHandlers.ts +272 -0
  177. package/src/main/ipc/index.ts +45 -0
  178. package/src/main/ipc/outputHandlers.ts +302 -0
  179. package/src/main/ipc/sessionHandlers.ts +56 -0
  180. package/src/main/ipc/settingsHandlers.ts +471 -0
  181. package/src/main/ipc/types.ts +56 -0
  182. package/src/main/ipc/windowHandlers.ts +277 -0
  183. package/src/main/output/ClipboardService.ts +369 -0
  184. package/src/main/output/ExportService.ts +539 -0
  185. package/src/main/output/FileManager.ts +416 -0
  186. package/src/main/output/MarkdownGenerator.ts +791 -0
  187. package/src/main/output/MarkdownPatcher.ts +299 -0
  188. package/src/main/output/index.ts +186 -0
  189. package/src/main/output/sessionAdapter.ts +207 -0
  190. package/src/main/output/templates/html-template.ts +553 -0
  191. package/src/main/pipeline/FrameExtractor.ts +330 -0
  192. package/src/main/pipeline/PostProcessor.ts +399 -0
  193. package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
  194. package/src/main/pipeline/index.ts +36 -0
  195. package/src/main/platform/WindowsTaskbar.ts +600 -0
  196. package/src/main/platform/index.ts +16 -0
  197. package/src/main/settings/SettingsManager.ts +730 -0
  198. package/src/main/settings/index.ts +19 -0
  199. package/src/main/transcription/ModelDownloadManager.ts +494 -0
  200. package/src/main/transcription/TierManager.ts +219 -0
  201. package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
  202. package/src/main/transcription/WhisperService.ts +748 -0
  203. package/src/main/transcription/index.ts +56 -0
  204. package/src/main/transcription/types.ts +135 -0
  205. package/src/main/windows/PopoverManager.ts +284 -0
  206. package/src/main/windows/TaskbarIntegration.ts +452 -0
  207. package/src/main/windows/index.ts +23 -0
  208. package/src/preload/index.ts +1047 -0
  209. package/src/renderer/App.tsx +515 -0
  210. package/src/renderer/AppWrapper.tsx +28 -0
  211. package/src/renderer/assets/logo-dark.svg +7 -0
  212. package/src/renderer/assets/logo.svg +7 -0
  213. package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
  214. package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
  215. package/src/renderer/components/AnnotationOverlay.tsx +836 -0
  216. package/src/renderer/components/AudioWaveform.tsx +811 -0
  217. package/src/renderer/components/ClarificationQuestions.tsx +656 -0
  218. package/src/renderer/components/CountdownTimer.tsx +495 -0
  219. package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
  220. package/src/renderer/components/DonateButton.tsx +127 -0
  221. package/src/renderer/components/ErrorBoundary.tsx +308 -0
  222. package/src/renderer/components/ExportDialog.tsx +872 -0
  223. package/src/renderer/components/HotkeyHint.tsx +261 -0
  224. package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
  225. package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
  226. package/src/renderer/components/Onboarding.tsx +1830 -0
  227. package/src/renderer/components/ProcessingOverlay.tsx +157 -0
  228. package/src/renderer/components/RecordingOverlay.tsx +423 -0
  229. package/src/renderer/components/SessionHistory.tsx +1746 -0
  230. package/src/renderer/components/SessionReview.tsx +1321 -0
  231. package/src/renderer/components/SettingsPanel.tsx +217 -0
  232. package/src/renderer/components/Skeleton.tsx +347 -0
  233. package/src/renderer/components/StatusIndicator.tsx +86 -0
  234. package/src/renderer/components/ThemeProvider.tsx +429 -0
  235. package/src/renderer/components/Tooltip.tsx +370 -0
  236. package/src/renderer/components/TranscriptionPreview.tsx +183 -0
  237. package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
  238. package/src/renderer/components/UpdateNotification.tsx +377 -0
  239. package/src/renderer/components/WindowSelector.tsx +947 -0
  240. package/src/renderer/components/index.ts +99 -0
  241. package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
  242. package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
  243. package/src/renderer/components/primitives/DangerButton.tsx +45 -0
  244. package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
  245. package/src/renderer/components/primitives/Dropdown.tsx +34 -0
  246. package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
  247. package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
  248. package/src/renderer/components/primitives/Slider.tsx +43 -0
  249. package/src/renderer/components/primitives/Toggle.tsx +36 -0
  250. package/src/renderer/components/primitives/index.ts +10 -0
  251. package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
  252. package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
  253. package/src/renderer/components/settings/GeneralTab.tsx +40 -0
  254. package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
  255. package/src/renderer/components/settings/RecordingTab.tsx +84 -0
  256. package/src/renderer/components/settings/index.ts +9 -0
  257. package/src/renderer/components/settings/settingsStyles.ts +673 -0
  258. package/src/renderer/components/settings/tabConfig.tsx +85 -0
  259. package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
  260. package/src/renderer/contexts/ProcessingContext.tsx +227 -0
  261. package/src/renderer/contexts/RecordingContext.tsx +683 -0
  262. package/src/renderer/contexts/UIContext.tsx +326 -0
  263. package/src/renderer/contexts/index.ts +24 -0
  264. package/src/renderer/donateMessages.ts +69 -0
  265. package/src/renderer/hooks/index.ts +75 -0
  266. package/src/renderer/hooks/useAnimation.tsx +544 -0
  267. package/src/renderer/hooks/useTheme.ts +313 -0
  268. package/src/renderer/index.html +26 -0
  269. package/src/renderer/main.tsx +52 -0
  270. package/src/renderer/styles/animations.css +1093 -0
  271. package/src/renderer/styles/app-shell.css +662 -0
  272. package/src/renderer/styles/globals.css +515 -0
  273. package/src/renderer/styles/theme.ts +578 -0
  274. package/src/renderer/types/electron.d.ts +385 -0
  275. package/src/shared/hotkeys.ts +283 -0
  276. package/src/shared/types.ts +809 -0
  277. package/tests/clipboard.test.ts +228 -0
  278. package/tests/e2e/criticalPaths.test.ts +594 -0
  279. package/tests/feedbackAnalyzer.test.ts +303 -0
  280. package/tests/integration/sessionFlow.test.ts +583 -0
  281. package/tests/markdownGenerator.test.ts +418 -0
  282. package/tests/output.test.ts +96 -0
  283. package/tests/setup.ts +486 -0
  284. package/tests/unit/appIntegration.test.ts +676 -0
  285. package/tests/unit/appViewState.test.ts +281 -0
  286. package/tests/unit/audioIpcChannels.test.ts +17 -0
  287. package/tests/unit/exportService.test.ts +492 -0
  288. package/tests/unit/hotkeys.test.ts +92 -0
  289. package/tests/unit/navigationPreload.test.ts +94 -0
  290. package/tests/unit/onboardingFlow.test.ts +345 -0
  291. package/tests/unit/permissionManager.test.ts +175 -0
  292. package/tests/unit/permissionManagerExpanded.test.ts +296 -0
  293. package/tests/unit/screenRecordingRenderer.test.ts +368 -0
  294. package/tests/unit/sessionController.test.ts +515 -0
  295. package/tests/unit/tierManager.test.ts +61 -0
  296. package/tests/unit/tierManagerExpanded.test.ts +142 -0
  297. package/tests/unit/transcriptAnalyzer.test.ts +64 -0
  298. package/tsconfig.json +25 -0
  299. package/vitest.config.ts +46 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * PermissionManager Expanded Tests
3
+ *
4
+ * Extends the existing permissionManager.test.ts with:
5
+ * - requestPermission flow
6
+ * - showStartupPermissionDialog behavior
7
+ * - openSystemPreferences URL construction
8
+ * - getPermissionStateDescription output
9
+ * - Non-macOS fallback behavior
10
+ * - Edge cases: restricted status, dialog interactions
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ import { systemPreferences, dialog, shell, app } from 'electron';
15
+
16
+ // Mock ErrorHandler (must be before import)
17
+ vi.mock('../../src/main/ErrorHandler', () => ({
18
+ errorHandler: {
19
+ log: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ import PermissionManager from '../../src/main/PermissionManager';
24
+
25
+ // Skip in CI — these tests require Electron's systemPreferences (macOS only)
26
+ const describeOrSkip = process.env.CI ? describe.skip : describe;
27
+
28
+ describeOrSkip('PermissionManager (expanded)', () => {
29
+ let manager: PermissionManager;
30
+
31
+ beforeEach(() => {
32
+ manager = new PermissionManager();
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ // ========================================================================
37
+ // getPermissionStateDescription
38
+ // ========================================================================
39
+
40
+ describe('getPermissionStateDescription', () => {
41
+ it('returns "Enabled" for granted', () => {
42
+ expect(manager.getPermissionStateDescription('microphone', 'granted')).toBe('Enabled');
43
+ });
44
+
45
+ it('returns denied message for denied', () => {
46
+ const desc = manager.getPermissionStateDescription('microphone', 'denied');
47
+ expect(desc).toContain('Denied');
48
+ expect(desc).toContain('System Settings');
49
+ });
50
+
51
+ it('returns not-set message for not-determined', () => {
52
+ const desc = manager.getPermissionStateDescription('screen', 'not-determined');
53
+ expect(desc).toContain('Not set');
54
+ });
55
+
56
+ it('returns restricted message for restricted', () => {
57
+ const desc = manager.getPermissionStateDescription('screen', 'restricted');
58
+ expect(desc).toContain('Restricted');
59
+ });
60
+
61
+ it('returns Unknown for unknown states', () => {
62
+ expect(manager.getPermissionStateDescription('microphone', 'something-else')).toBe('Unknown');
63
+ });
64
+ });
65
+
66
+ // ========================================================================
67
+ // requestPermission - microphone
68
+ // ========================================================================
69
+
70
+ describe('requestPermission - microphone', () => {
71
+ it('returns true if already granted', async () => {
72
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
73
+
74
+ const result = await manager.requestPermission('microphone');
75
+
76
+ expect(result).toBe(true);
77
+ });
78
+
79
+ it('triggers system prompt for not-determined and returns true if granted', async () => {
80
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
81
+ vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(true);
82
+
83
+ const result = await manager.requestPermission('microphone');
84
+
85
+ expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('microphone');
86
+ expect(result).toBe(true);
87
+ });
88
+
89
+ it('shows dialog when not-determined and user denies system prompt', async () => {
90
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
91
+ vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(false);
92
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
93
+
94
+ const result = await manager.requestPermission('microphone');
95
+
96
+ expect(result).toBe(false);
97
+ });
98
+
99
+ it('shows dialog for denied status', async () => {
100
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('denied');
101
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
102
+
103
+ const result = await manager.requestPermission('microphone');
104
+
105
+ expect(dialog.showMessageBox).toHaveBeenCalled();
106
+ expect(result).toBe(false);
107
+ });
108
+ });
109
+
110
+ // ========================================================================
111
+ // requestPermission - screen
112
+ // ========================================================================
113
+
114
+ describe('requestPermission - screen', () => {
115
+ it('returns true if already granted', async () => {
116
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
117
+
118
+ const result = await manager.requestPermission('screen');
119
+
120
+ expect(result).toBe(true);
121
+ });
122
+
123
+ it('shows dialog for denied status', async () => {
124
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('denied');
125
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
126
+
127
+ const result = await manager.requestPermission('screen');
128
+
129
+ expect(result).toBe(false);
130
+ });
131
+ });
132
+
133
+ // ========================================================================
134
+ // requestPermission - accessibility
135
+ // ========================================================================
136
+
137
+ describe('requestPermission - accessibility', () => {
138
+ it('returns true if already trusted', async () => {
139
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
140
+ vi.mocked(systemPreferences.isTrustedAccessibilityClient).mockReturnValue(true);
141
+
142
+ const result = await manager.requestPermission('accessibility');
143
+
144
+ expect(result).toBe(true);
145
+ });
146
+
147
+ it('shows dialog when not trusted', async () => {
148
+ // First call: getPermissionStatus check returns 'denied'
149
+ vi.mocked(systemPreferences.isTrustedAccessibilityClient).mockReturnValue(false);
150
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
151
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
152
+
153
+ const result = await manager.requestPermission('accessibility');
154
+
155
+ expect(result).toBe(false);
156
+ });
157
+ });
158
+
159
+ // ========================================================================
160
+ // showStartupPermissionDialog
161
+ // ========================================================================
162
+
163
+ describe('showStartupPermissionDialog', () => {
164
+ it('does nothing when no permissions missing', async () => {
165
+ await manager.showStartupPermissionDialog([]);
166
+
167
+ expect(dialog.showMessageBox).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it('shows dialog listing missing permissions', async () => {
171
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
172
+
173
+ await manager.showStartupPermissionDialog(['microphone', 'screen']);
174
+
175
+ expect(dialog.showMessageBox).toHaveBeenCalled();
176
+ const options = vi.mocked(dialog.showMessageBox).mock.calls[0][0] as Electron.MessageBoxOptions;
177
+ expect(options.detail).toContain('Microphone Access');
178
+ expect(options.detail).toContain('Screen Recording');
179
+ });
180
+
181
+ it('opens system preferences when user clicks "Set Up Permissions"', async () => {
182
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false });
183
+
184
+ await manager.showStartupPermissionDialog(['microphone']);
185
+
186
+ expect(shell.openExternal).toHaveBeenCalledWith(
187
+ expect.stringContaining('Privacy_Microphone')
188
+ );
189
+ });
190
+
191
+ it('quits app when user clicks "Quit"', async () => {
192
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 2, checkboxChecked: false });
193
+
194
+ await manager.showStartupPermissionDialog(['microphone']);
195
+
196
+ expect(app.quit).toHaveBeenCalled();
197
+ });
198
+
199
+ it('does nothing when user clicks "Continue Anyway"', async () => {
200
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
201
+
202
+ await manager.showStartupPermissionDialog(['microphone']);
203
+
204
+ expect(shell.openExternal).not.toHaveBeenCalled();
205
+ expect(app.quit).not.toHaveBeenCalled();
206
+ });
207
+ });
208
+
209
+ // ========================================================================
210
+ // openSystemPreferences
211
+ // ========================================================================
212
+
213
+ describe('openSystemPreferences', () => {
214
+ it('opens microphone privacy pane on macOS', async () => {
215
+ await manager.openSystemPreferences('microphone');
216
+
217
+ expect(shell.openExternal).toHaveBeenCalledWith(
218
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
219
+ );
220
+ });
221
+
222
+ it('opens screen capture privacy pane on macOS', async () => {
223
+ await manager.openSystemPreferences('screen');
224
+
225
+ expect(shell.openExternal).toHaveBeenCalledWith(
226
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
227
+ );
228
+ });
229
+
230
+ it('opens accessibility privacy pane on macOS', async () => {
231
+ await manager.openSystemPreferences('accessibility');
232
+
233
+ expect(shell.openExternal).toHaveBeenCalledWith(
234
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
235
+ );
236
+ });
237
+ });
238
+
239
+ // ========================================================================
240
+ // Error handling
241
+ // ========================================================================
242
+
243
+ describe('error handling', () => {
244
+ it('returns unknown when getMediaAccessStatus throws', async () => {
245
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockImplementation(() => {
246
+ throw new Error('System API unavailable');
247
+ });
248
+
249
+ const status = await manager.getPermissionStatus('microphone');
250
+
251
+ expect(status).toBe('unknown');
252
+ });
253
+
254
+ it('returns unknown when isTrustedAccessibilityClient throws', async () => {
255
+ vi.mocked(systemPreferences.isTrustedAccessibilityClient).mockImplementation(() => {
256
+ throw new Error('System API unavailable');
257
+ });
258
+
259
+ const status = await manager.getPermissionStatus('accessibility');
260
+
261
+ expect(status).toBe('unknown');
262
+ });
263
+
264
+ it('isGranted returns false for unknown status', async () => {
265
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockImplementation(() => {
266
+ throw new Error('System error');
267
+ });
268
+
269
+ const granted = await manager.isGranted('microphone');
270
+
271
+ expect(granted).toBe(false);
272
+ });
273
+ });
274
+
275
+ // ========================================================================
276
+ // setMainWindow
277
+ // ========================================================================
278
+
279
+ describe('setMainWindow', () => {
280
+ it('uses main window for dialog when set', async () => {
281
+ const mockWindow = { webContents: { send: vi.fn() } };
282
+ manager.setMainWindow(mockWindow as never);
283
+
284
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('denied');
285
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
286
+
287
+ await manager.requestPermission('microphone');
288
+
289
+ // When mainWindow is set, dialog.showMessageBox is called with window as first arg
290
+ expect(dialog.showMessageBox).toHaveBeenCalledWith(
291
+ mockWindow,
292
+ expect.objectContaining({ type: 'warning' })
293
+ );
294
+ });
295
+ });
296
+ });
@@ -0,0 +1,368 @@
1
+ /**
2
+ * ScreenRecordingRenderer Unit Tests
3
+ *
4
+ * Tests the renderer-side screen recording lifecycle:
5
+ * - Start/stop recording
6
+ * - Chunk streaming to main process via IPC
7
+ * - Error handling and cleanup
8
+ * - Guard against double-start, double-stop
9
+ * - In-flight write draining on stop
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock MediaRecorder + MediaStream + navigator.mediaDevices
16
+ // ---------------------------------------------------------------------------
17
+
18
+ class MockMediaStream {
19
+ private tracks: Array<{ stop: ReturnType<typeof vi.fn>; kind: string }> = [];
20
+
21
+ constructor() {
22
+ this.tracks = [{ stop: vi.fn(), kind: 'video' }];
23
+ }
24
+
25
+ getTracks() {
26
+ return this.tracks;
27
+ }
28
+ }
29
+
30
+ let mockRecorderInstance: {
31
+ state: string;
32
+ start: ReturnType<typeof vi.fn>;
33
+ stop: ReturnType<typeof vi.fn>;
34
+ requestData: ReturnType<typeof vi.fn>;
35
+ ondataavailable: ((event: { data: Blob }) => void) | null;
36
+ onstop: (() => void) | null;
37
+ };
38
+
39
+ class MockMediaRecorder {
40
+ static isTypeSupported = vi.fn(() => true);
41
+ state = 'inactive';
42
+ ondataavailable: ((event: { data: Blob }) => void) | null = null;
43
+ onstop: (() => void) | null = null;
44
+ requestData = vi.fn();
45
+ start = vi.fn(() => {
46
+ this.state = 'recording';
47
+ });
48
+ stop = vi.fn(() => {
49
+ this.state = 'inactive';
50
+ // Fire onstop async
51
+ setTimeout(() => this.onstop?.(), 0);
52
+ });
53
+
54
+ constructor() {
55
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
56
+ mockRecorderInstance = this;
57
+ }
58
+ }
59
+
60
+ vi.stubGlobal('MediaRecorder', MockMediaRecorder);
61
+
62
+ vi.stubGlobal('navigator', {
63
+ mediaDevices: {
64
+ getUserMedia: vi.fn(() => Promise.resolve(new MockMediaStream())),
65
+ },
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Mock window.markupr.screenRecording IPC
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const mockScreenRecordingIPC = {
73
+ start: vi.fn(() => Promise.resolve({ success: true, path: '/tmp/rec.webm' })),
74
+ appendChunk: vi.fn(() => Promise.resolve({ success: true })),
75
+ stop: vi.fn(() =>
76
+ Promise.resolve({ success: true, path: '/tmp/rec.webm', bytes: 1024, mimeType: 'video/webm' })
77
+ ),
78
+ };
79
+
80
+ vi.stubGlobal('window', {
81
+ markupr: {
82
+ screenRecording: mockScreenRecordingIPC,
83
+ },
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Import AFTER mocks are in place
88
+ // ---------------------------------------------------------------------------
89
+
90
+ import { ScreenRecordingRenderer } from '../../src/renderer/capture/ScreenRecordingRenderer';
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Tests
94
+ // ---------------------------------------------------------------------------
95
+
96
+ describe('ScreenRecordingRenderer', () => {
97
+ let renderer: ScreenRecordingRenderer;
98
+
99
+ beforeEach(() => {
100
+ renderer = new ScreenRecordingRenderer();
101
+ vi.clearAllMocks();
102
+ });
103
+
104
+ afterEach(async () => {
105
+ // Ensure cleanup
106
+ if (renderer.isRecording()) {
107
+ await renderer.stop();
108
+ }
109
+ });
110
+
111
+ // ========================================================================
112
+ // Initial state
113
+ // ========================================================================
114
+
115
+ describe('initial state', () => {
116
+ it('should not be recording initially', () => {
117
+ expect(renderer.isRecording()).toBe(false);
118
+ });
119
+
120
+ it('should have null sessionId initially', () => {
121
+ expect(renderer.getSessionId()).toBeNull();
122
+ });
123
+ });
124
+
125
+ // ========================================================================
126
+ // Start recording
127
+ // ========================================================================
128
+
129
+ describe('start', () => {
130
+ it('should request getUserMedia with desktop source constraints', async () => {
131
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
132
+
133
+ expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ audio: false,
136
+ video: expect.objectContaining({
137
+ mandatory: expect.objectContaining({
138
+ chromeMediaSource: 'desktop',
139
+ chromeMediaSourceId: 'screen:0:0',
140
+ }),
141
+ }),
142
+ })
143
+ );
144
+ });
145
+
146
+ it('should call IPC start with sessionId and mimeType', async () => {
147
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
148
+
149
+ expect(mockScreenRecordingIPC.start).toHaveBeenCalledWith('sess-1', expect.any(String), expect.any(Number));
150
+ });
151
+
152
+ it('should set isRecording to true after start', async () => {
153
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
154
+
155
+ expect(renderer.isRecording()).toBe(true);
156
+ });
157
+
158
+ it('should set sessionId after start', async () => {
159
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
160
+
161
+ expect(renderer.getSessionId()).toBe('sess-1');
162
+ });
163
+
164
+ it('should set recordingStartTime after start', async () => {
165
+ const before = Date.now();
166
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
167
+ const after = Date.now();
168
+
169
+ const startTime = renderer.getRecordingStartTime();
170
+ expect(startTime).toBeTypeOf('number');
171
+ expect(startTime).toBeGreaterThanOrEqual(before);
172
+ expect(startTime).toBeLessThanOrEqual(after);
173
+ });
174
+
175
+ it('should start MediaRecorder with 1000ms timeslice', async () => {
176
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
177
+
178
+ expect(mockRecorderInstance.start).toHaveBeenCalledWith(1000);
179
+ });
180
+
181
+ it('should no-op if already recording', async () => {
182
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
183
+ vi.clearAllMocks();
184
+
185
+ await renderer.start({ sessionId: 'sess-2', sourceId: 'screen:0:0' });
186
+
187
+ // Should NOT have called getUserMedia again
188
+ expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
189
+ // Session ID should still be the first one
190
+ expect(renderer.getSessionId()).toBe('sess-1');
191
+ });
192
+
193
+ it('should throw if IPC start fails', async () => {
194
+ mockScreenRecordingIPC.start.mockResolvedValueOnce({ success: false, error: 'disk full' });
195
+
196
+ await expect(
197
+ renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' })
198
+ ).rejects.toThrow('disk full');
199
+ });
200
+
201
+ it('should stop tracks if IPC start fails', async () => {
202
+ const mockStream = new MockMediaStream();
203
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(
204
+ mockStream as unknown as MediaStream
205
+ );
206
+ mockScreenRecordingIPC.start.mockResolvedValueOnce({ success: false, error: 'fail' });
207
+
208
+ await expect(renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' })).rejects.toThrow();
209
+
210
+ expect(mockStream.getTracks()[0].stop).toHaveBeenCalled();
211
+ });
212
+
213
+ it('should fall back to basic constraints if high-quality fails', async () => {
214
+ let callCount = 0;
215
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockImplementation(() => {
216
+ callCount++;
217
+ if (callCount === 1) {
218
+ return Promise.reject(new Error('OverconstrainedError'));
219
+ }
220
+ return Promise.resolve(new MockMediaStream() as unknown as MediaStream);
221
+ });
222
+
223
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
224
+
225
+ expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
226
+ expect(renderer.isRecording()).toBe(true);
227
+ });
228
+ });
229
+
230
+ // ========================================================================
231
+ // Chunk streaming
232
+ // ========================================================================
233
+
234
+ describe('chunk streaming', () => {
235
+ it('should send chunks to IPC when data is available', async () => {
236
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
237
+
238
+ // Simulate a data chunk from MediaRecorder
239
+ const blob = new Blob(['test-data'], { type: 'video/webm' });
240
+ mockRecorderInstance.ondataavailable?.({ data: blob });
241
+
242
+ // Wait for async processing
243
+ await new Promise((r) => setTimeout(r, 10));
244
+
245
+ expect(mockScreenRecordingIPC.appendChunk).toHaveBeenCalledWith(
246
+ 'sess-1',
247
+ expect.any(Uint8Array)
248
+ );
249
+ });
250
+
251
+ it('should ignore empty chunks', async () => {
252
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
253
+
254
+ const emptyBlob = new Blob([], { type: 'video/webm' });
255
+ mockRecorderInstance.ondataavailable?.({ data: emptyBlob });
256
+
257
+ await new Promise((r) => setTimeout(r, 10));
258
+
259
+ expect(mockScreenRecordingIPC.appendChunk).not.toHaveBeenCalled();
260
+ });
261
+ });
262
+
263
+ // ========================================================================
264
+ // Stop recording
265
+ // ========================================================================
266
+
267
+ describe('stop', () => {
268
+ it('should return success immediately if not recording', async () => {
269
+ const result = await renderer.stop();
270
+
271
+ expect(result).toEqual({ success: true });
272
+ });
273
+
274
+ it('should call IPC stop with sessionId', async () => {
275
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
276
+ await renderer.stop();
277
+
278
+ expect(mockScreenRecordingIPC.stop).toHaveBeenCalledWith('sess-1');
279
+ });
280
+
281
+ it('should reset state after stop', async () => {
282
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
283
+ await renderer.stop();
284
+
285
+ expect(renderer.isRecording()).toBe(false);
286
+ expect(renderer.getSessionId()).toBeNull();
287
+ });
288
+
289
+ it('should stop media stream tracks on stop', async () => {
290
+ const mockStream = new MockMediaStream();
291
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(
292
+ mockStream as unknown as MediaStream
293
+ );
294
+
295
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
296
+ await renderer.stop();
297
+
298
+ expect(mockStream.getTracks()[0].stop).toHaveBeenCalled();
299
+ });
300
+
301
+ it('should return IPC stop result', async () => {
302
+ const expected = { success: true, path: '/tmp/rec.webm', bytes: 2048, mimeType: 'video/webm' };
303
+ mockScreenRecordingIPC.stop.mockResolvedValueOnce(expected);
304
+
305
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
306
+ const result = await renderer.stop();
307
+
308
+ expect(result).toEqual(expected);
309
+ });
310
+
311
+ it('should request a final recorder data flush before stop', async () => {
312
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
313
+ await renderer.stop();
314
+
315
+ expect(mockRecorderInstance.requestData).toHaveBeenCalled();
316
+ });
317
+
318
+ it('should still stop tracks if IPC finalize fails', async () => {
319
+ const mockStream = new MockMediaStream();
320
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(
321
+ mockStream as unknown as MediaStream
322
+ );
323
+ mockScreenRecordingIPC.stop.mockRejectedValueOnce(new Error('ipc stop failed'));
324
+
325
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
326
+ const result = await renderer.stop();
327
+
328
+ expect(result.success).toBe(false);
329
+ expect(mockStream.getTracks()[0].stop).toHaveBeenCalled();
330
+ });
331
+
332
+ it('should no-op on double stop', async () => {
333
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
334
+ await renderer.stop();
335
+
336
+ vi.clearAllMocks();
337
+
338
+ const result = await renderer.stop();
339
+
340
+ expect(result).toEqual({ success: true });
341
+ expect(mockScreenRecordingIPC.stop).not.toHaveBeenCalled();
342
+ });
343
+ });
344
+
345
+ // ========================================================================
346
+ // MIME type selection
347
+ // ========================================================================
348
+
349
+ describe('MIME type selection', () => {
350
+ it('should use first supported MIME type', async () => {
351
+ MockMediaRecorder.isTypeSupported.mockImplementation(
352
+ (type: string) => type === 'video/webm;codecs=vp9'
353
+ );
354
+
355
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
356
+
357
+ expect(mockScreenRecordingIPC.start).toHaveBeenCalledWith('sess-1', 'video/webm;codecs=vp9', expect.any(Number));
358
+ });
359
+
360
+ it('should fall back to video/webm when no codecs supported', async () => {
361
+ MockMediaRecorder.isTypeSupported.mockReturnValue(false);
362
+
363
+ await renderer.start({ sessionId: 'sess-1', sourceId: 'screen:0:0' });
364
+
365
+ expect(mockScreenRecordingIPC.start).toHaveBeenCalledWith('sess-1', 'video/webm', expect.any(Number));
366
+ });
367
+ });
368
+ });