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,492 @@
1
+ /**
2
+ * ExportService Unit Tests
3
+ *
4
+ * Tests the multi-format export functionality:
5
+ * - JSON export schema
6
+ * - Filename generation
7
+ * - Format info
8
+ * - Preview generation
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
12
+ import type { Session, FeedbackItem } from '../../src/main/output/MarkdownGenerator';
13
+
14
+ // =============================================================================
15
+ // Mock Electron and dependencies
16
+ // =============================================================================
17
+
18
+ vi.mock('electron', () => ({
19
+ app: {
20
+ getVersion: vi.fn(() => '0.4.0'),
21
+ getPath: vi.fn(() => '/tmp'),
22
+ },
23
+ BrowserWindow: vi.fn().mockImplementation(() => ({
24
+ loadURL: vi.fn(() => Promise.resolve()),
25
+ webContents: {
26
+ printToPDF: vi.fn(() => Promise.resolve(Buffer.from('PDF'))),
27
+ },
28
+ destroy: vi.fn(),
29
+ })),
30
+ }));
31
+
32
+ vi.mock('fs/promises', () => ({
33
+ mkdir: vi.fn(() => Promise.resolve()),
34
+ writeFile: vi.fn(() => Promise.resolve()),
35
+ stat: vi.fn(() => Promise.resolve({ size: 1024 })),
36
+ }));
37
+
38
+ // =============================================================================
39
+ // Test Implementation (Isolated from Electron dependencies)
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Isolated ExportService for testing without Electron dependencies
44
+ */
45
+ class TestableExportService {
46
+ /**
47
+ * Generate JSON export data structure
48
+ */
49
+ generateJsonExport(session: Session, includeBase64Images: boolean) {
50
+ const categories = this.countByCategory(session.feedbackItems);
51
+ const severities = this.countBySeverity(session.feedbackItems);
52
+ const screenshotCount = session.feedbackItems.reduce(
53
+ (sum, item) => sum + item.screenshots.length,
54
+ 0
55
+ );
56
+ const duration = session.endTime ? session.endTime - session.startTime : 0;
57
+
58
+ return {
59
+ version: '1.0',
60
+ generator: 'markupr v0.4.0',
61
+ exportedAt: new Date().toISOString(),
62
+ session: {
63
+ id: session.id,
64
+ startTime: session.startTime,
65
+ endTime: session.endTime,
66
+ source: {
67
+ name: session.metadata?.sourceName,
68
+ type: session.metadata?.sourceType,
69
+ os: session.metadata?.os,
70
+ },
71
+ items: session.feedbackItems.map((item, index) => ({
72
+ id: item.id,
73
+ index,
74
+ timestamp: item.timestamp,
75
+ transcription: item.transcription,
76
+ category: item.category || null,
77
+ severity: item.severity || null,
78
+ screenshots: item.screenshots.map((ss) => ({
79
+ id: ss.id,
80
+ width: ss.width,
81
+ height: ss.height,
82
+ ...(includeBase64Images && ss.base64 ? { base64: ss.base64 } : {}),
83
+ })),
84
+ })),
85
+ },
86
+ summary: {
87
+ itemCount: session.feedbackItems.length,
88
+ screenshotCount,
89
+ duration,
90
+ categories,
91
+ severities,
92
+ },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Get suggested filename for a given format
98
+ */
99
+ getSuggestedFilename(
100
+ session: Session,
101
+ format: 'markdown' | 'pdf' | 'html' | 'json',
102
+ projectName?: string
103
+ ): string {
104
+ const name = (projectName || session.metadata?.sourceName || 'feedback')
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9]/g, '-')
107
+ .replace(/-+/g, '-')
108
+ .replace(/^-+|-+$/g, '');
109
+
110
+ const date = new Date(session.startTime);
111
+ const dateStr = [
112
+ date.getFullYear(),
113
+ String(date.getMonth() + 1).padStart(2, '0'),
114
+ String(date.getDate()).padStart(2, '0'),
115
+ ].join('');
116
+ const timeStr = [
117
+ String(date.getHours()).padStart(2, '0'),
118
+ String(date.getMinutes()).padStart(2, '0'),
119
+ ].join('');
120
+
121
+ const extensions: Record<string, string> = {
122
+ markdown: 'md',
123
+ pdf: 'pdf',
124
+ html: 'html',
125
+ json: 'json',
126
+ };
127
+
128
+ return `${name}-feedback-${dateStr}-${timeStr}.${extensions[format]}`;
129
+ }
130
+
131
+ /**
132
+ * Get format info for UI display
133
+ */
134
+ getFormatInfo(format: 'markdown' | 'pdf' | 'html' | 'json') {
135
+ const info = {
136
+ markdown: {
137
+ name: 'Markdown',
138
+ description: 'AI-ready format for Claude, ChatGPT, and other assistants',
139
+ icon: 'document-text',
140
+ extension: '.md',
141
+ },
142
+ pdf: {
143
+ name: 'PDF',
144
+ description: 'Beautiful document for sharing and printing',
145
+ icon: 'document',
146
+ extension: '.pdf',
147
+ },
148
+ html: {
149
+ name: 'HTML',
150
+ description: 'Standalone web page, no dependencies',
151
+ icon: 'code-bracket',
152
+ extension: '.html',
153
+ },
154
+ json: {
155
+ name: 'JSON',
156
+ description: 'Machine-readable for integrations and APIs',
157
+ icon: 'code-bracket-square',
158
+ extension: '.json',
159
+ },
160
+ };
161
+
162
+ return info[format];
163
+ }
164
+
165
+ private countByCategory(items: FeedbackItem[]): Record<string, number> {
166
+ return items.reduce((acc, item) => {
167
+ const category = item.category || 'General';
168
+ acc[category] = (acc[category] || 0) + 1;
169
+ return acc;
170
+ }, {} as Record<string, number>);
171
+ }
172
+
173
+ private countBySeverity(items: FeedbackItem[]): Record<string, number> {
174
+ return items.reduce((acc, item) => {
175
+ const severity = item.severity || 'Medium';
176
+ acc[severity] = (acc[severity] || 0) + 1;
177
+ return acc;
178
+ }, {} as Record<string, number>);
179
+ }
180
+ }
181
+
182
+ // =============================================================================
183
+ // Test Data
184
+ // =============================================================================
185
+
186
+ function createTestSession(overrides: Partial<Session> = {}): Session {
187
+ const now = Date.now();
188
+ return {
189
+ id: 'test-session-123',
190
+ startTime: new Date('2024-06-15T14:30:00').getTime(),
191
+ endTime: new Date('2024-06-15T14:35:30').getTime(),
192
+ feedbackItems: [
193
+ {
194
+ id: 'item-1',
195
+ transcription: 'The save button is broken.',
196
+ timestamp: new Date('2024-06-15T14:31:00').getTime(),
197
+ screenshots: [
198
+ {
199
+ id: 'ss-1',
200
+ timestamp: new Date('2024-06-15T14:31:05').getTime(),
201
+ imagePath: '/tmp/ss-1.png',
202
+ width: 1920,
203
+ height: 1080,
204
+ },
205
+ ],
206
+ category: 'Bug',
207
+ severity: 'High',
208
+ },
209
+ {
210
+ id: 'item-2',
211
+ transcription: 'The navigation is confusing.',
212
+ timestamp: new Date('2024-06-15T14:33:00').getTime(),
213
+ screenshots: [],
214
+ category: 'UX Issue',
215
+ severity: 'Medium',
216
+ },
217
+ {
218
+ id: 'item-3',
219
+ transcription: 'Would be nice to have dark mode.',
220
+ timestamp: new Date('2024-06-15T14:34:00').getTime(),
221
+ screenshots: [
222
+ {
223
+ id: 'ss-2',
224
+ timestamp: new Date('2024-06-15T14:34:05').getTime(),
225
+ imagePath: '/tmp/ss-2.png',
226
+ width: 1920,
227
+ height: 1080,
228
+ base64: 'data:image/png;base64,ABC123',
229
+ },
230
+ ],
231
+ category: 'Suggestion',
232
+ severity: 'Low',
233
+ },
234
+ ],
235
+ metadata: {
236
+ os: 'darwin',
237
+ sourceName: 'My Test App',
238
+ sourceType: 'window',
239
+ },
240
+ ...overrides,
241
+ };
242
+ }
243
+
244
+ // =============================================================================
245
+ // Tests
246
+ // =============================================================================
247
+
248
+ describe('ExportService', () => {
249
+ let service: TestableExportService;
250
+
251
+ beforeEach(() => {
252
+ service = new TestableExportService();
253
+ });
254
+
255
+ describe('generateJsonExport', () => {
256
+ it('should generate valid JSON export schema', () => {
257
+ const session = createTestSession();
258
+ const json = service.generateJsonExport(session, false);
259
+
260
+ expect(json.version).toBe('1.0');
261
+ expect(json.generator).toContain('markupr');
262
+ expect(json.exportedAt).toBeDefined();
263
+ });
264
+
265
+ it('should include session metadata', () => {
266
+ const session = createTestSession();
267
+ const json = service.generateJsonExport(session, false);
268
+
269
+ expect(json.session.id).toBe('test-session-123');
270
+ expect(json.session.startTime).toBeDefined();
271
+ expect(json.session.endTime).toBeDefined();
272
+ expect(json.session.source.name).toBe('My Test App');
273
+ expect(json.session.source.type).toBe('window');
274
+ expect(json.session.source.os).toBe('darwin');
275
+ });
276
+
277
+ it('should include all feedback items', () => {
278
+ const session = createTestSession();
279
+ const json = service.generateJsonExport(session, false);
280
+
281
+ expect(json.session.items).toHaveLength(3);
282
+ expect(json.session.items[0].transcription).toBe('The save button is broken.');
283
+ expect(json.session.items[0].category).toBe('Bug');
284
+ expect(json.session.items[0].severity).toBe('High');
285
+ });
286
+
287
+ it('should include item indices', () => {
288
+ const session = createTestSession();
289
+ const json = service.generateJsonExport(session, false);
290
+
291
+ expect(json.session.items[0].index).toBe(0);
292
+ expect(json.session.items[1].index).toBe(1);
293
+ expect(json.session.items[2].index).toBe(2);
294
+ });
295
+
296
+ it('should include screenshots without base64 by default', () => {
297
+ const session = createTestSession();
298
+ const json = service.generateJsonExport(session, false);
299
+
300
+ const itemWithScreenshot = json.session.items[0];
301
+ expect(itemWithScreenshot.screenshots).toHaveLength(1);
302
+ expect(itemWithScreenshot.screenshots[0].id).toBe('ss-1');
303
+ expect(itemWithScreenshot.screenshots[0].width).toBe(1920);
304
+ expect(itemWithScreenshot.screenshots[0].height).toBe(1080);
305
+ expect(itemWithScreenshot.screenshots[0].base64).toBeUndefined();
306
+ });
307
+
308
+ it('should include base64 when requested', () => {
309
+ const session = createTestSession();
310
+ const json = service.generateJsonExport(session, true);
311
+
312
+ // Item 3 has base64
313
+ const itemWithBase64 = json.session.items[2];
314
+ expect(itemWithBase64.screenshots[0].base64).toBe('data:image/png;base64,ABC123');
315
+ });
316
+
317
+ it('should generate correct summary', () => {
318
+ const session = createTestSession();
319
+ const json = service.generateJsonExport(session, false);
320
+
321
+ expect(json.summary.itemCount).toBe(3);
322
+ expect(json.summary.screenshotCount).toBe(2);
323
+ expect(json.summary.duration).toBeGreaterThan(0);
324
+ });
325
+
326
+ it('should count categories correctly', () => {
327
+ const session = createTestSession();
328
+ const json = service.generateJsonExport(session, false);
329
+
330
+ expect(json.summary.categories).toEqual({
331
+ Bug: 1,
332
+ 'UX Issue': 1,
333
+ Suggestion: 1,
334
+ });
335
+ });
336
+
337
+ it('should count severities correctly', () => {
338
+ const session = createTestSession();
339
+ const json = service.generateJsonExport(session, false);
340
+
341
+ expect(json.summary.severities).toEqual({
342
+ High: 1,
343
+ Medium: 1,
344
+ Low: 1,
345
+ });
346
+ });
347
+
348
+ it('should handle empty session', () => {
349
+ const session = createTestSession({
350
+ feedbackItems: [],
351
+ });
352
+ const json = service.generateJsonExport(session, false);
353
+
354
+ expect(json.session.items).toHaveLength(0);
355
+ expect(json.summary.itemCount).toBe(0);
356
+ expect(json.summary.screenshotCount).toBe(0);
357
+ expect(json.summary.categories).toEqual({});
358
+ });
359
+
360
+ it('should handle missing category/severity', () => {
361
+ const session = createTestSession({
362
+ feedbackItems: [
363
+ {
364
+ id: 'item-1',
365
+ transcription: 'Test',
366
+ timestamp: Date.now(),
367
+ screenshots: [],
368
+ // No category or severity
369
+ },
370
+ ],
371
+ });
372
+ const json = service.generateJsonExport(session, false);
373
+
374
+ expect(json.session.items[0].category).toBeNull();
375
+ expect(json.session.items[0].severity).toBeNull();
376
+ expect(json.summary.categories).toEqual({ General: 1 });
377
+ expect(json.summary.severities).toEqual({ Medium: 1 });
378
+ });
379
+ });
380
+
381
+ describe('getSuggestedFilename', () => {
382
+ it('should generate filename with project name', () => {
383
+ const session = createTestSession();
384
+ const filename = service.getSuggestedFilename(session, 'markdown', 'MyProject');
385
+
386
+ expect(filename).toMatch(/^myproject-feedback-\d{8}-\d{4}\.md$/);
387
+ });
388
+
389
+ it('should use sourceName when project name not provided', () => {
390
+ const session = createTestSession();
391
+ const filename = service.getSuggestedFilename(session, 'markdown');
392
+
393
+ expect(filename).toMatch(/^my-test-app-feedback-\d{8}-\d{4}\.md$/);
394
+ });
395
+
396
+ it('should default to "feedback" when no name available', () => {
397
+ const session = createTestSession({ metadata: undefined });
398
+ const filename = service.getSuggestedFilename(session, 'markdown');
399
+
400
+ expect(filename).toMatch(/^feedback-feedback-\d{8}-\d{4}\.md$/);
401
+ });
402
+
403
+ it('should use correct extension for each format', () => {
404
+ const session = createTestSession();
405
+
406
+ expect(service.getSuggestedFilename(session, 'markdown')).toMatch(/\.md$/);
407
+ expect(service.getSuggestedFilename(session, 'pdf')).toMatch(/\.pdf$/);
408
+ expect(service.getSuggestedFilename(session, 'html')).toMatch(/\.html$/);
409
+ expect(service.getSuggestedFilename(session, 'json')).toMatch(/\.json$/);
410
+ });
411
+
412
+ it('should sanitize special characters in project name', () => {
413
+ const session = createTestSession();
414
+ const filename = service.getSuggestedFilename(session, 'markdown', "Eddie's App (v2.0)");
415
+
416
+ expect(filename).not.toContain("'");
417
+ expect(filename).not.toContain('(');
418
+ expect(filename).not.toContain(')');
419
+ expect(filename).not.toContain(' ');
420
+ expect(filename).toMatch(/^eddie-s-app-v2-0-feedback/);
421
+ });
422
+
423
+ it('should collapse multiple dashes', () => {
424
+ const session = createTestSession();
425
+ const filename = service.getSuggestedFilename(session, 'markdown', 'Test --- App');
426
+
427
+ expect(filename).not.toMatch(/---/);
428
+ expect(filename).toMatch(/^test-app-feedback/);
429
+ });
430
+
431
+ it('should remove leading/trailing dashes', () => {
432
+ const session = createTestSession();
433
+ const filename = service.getSuggestedFilename(session, 'markdown', '---Test App---');
434
+
435
+ expect(filename).toMatch(/^test-app-feedback/);
436
+ });
437
+
438
+ it('should include date and time from session start', () => {
439
+ const session = createTestSession({
440
+ startTime: new Date('2024-12-25T09:30:00').getTime(),
441
+ });
442
+ const filename = service.getSuggestedFilename(session, 'json', 'Test');
443
+
444
+ expect(filename).toContain('20241225');
445
+ expect(filename).toContain('0930');
446
+ });
447
+ });
448
+
449
+ describe('getFormatInfo', () => {
450
+ it('should return info for markdown format', () => {
451
+ const info = service.getFormatInfo('markdown');
452
+
453
+ expect(info.name).toBe('Markdown');
454
+ expect(info.extension).toBe('.md');
455
+ expect(info.description).toContain('AI-ready');
456
+ });
457
+
458
+ it('should return info for PDF format', () => {
459
+ const info = service.getFormatInfo('pdf');
460
+
461
+ expect(info.name).toBe('PDF');
462
+ expect(info.extension).toBe('.pdf');
463
+ expect(info.description).toContain('sharing');
464
+ });
465
+
466
+ it('should return info for HTML format', () => {
467
+ const info = service.getFormatInfo('html');
468
+
469
+ expect(info.name).toBe('HTML');
470
+ expect(info.extension).toBe('.html');
471
+ expect(info.description).toContain('Standalone');
472
+ });
473
+
474
+ it('should return info for JSON format', () => {
475
+ const info = service.getFormatInfo('json');
476
+
477
+ expect(info.name).toBe('JSON');
478
+ expect(info.extension).toBe('.json');
479
+ expect(info.description).toContain('Machine-readable');
480
+ });
481
+
482
+ it('should include icons for all formats', () => {
483
+ const formats: Array<'markdown' | 'pdf' | 'html' | 'json'> = ['markdown', 'pdf', 'html', 'json'];
484
+
485
+ for (const format of formats) {
486
+ const info = service.getFormatInfo(format);
487
+ expect(info.icon).toBeDefined();
488
+ expect(info.icon.length).toBeGreaterThan(0);
489
+ }
490
+ });
491
+ });
492
+ });
@@ -0,0 +1,92 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ formatAcceleratorForDisplay,
4
+ formatHotkeyConfigForDisplay,
5
+ formatHotkeyForDisplay,
6
+ getAccelerator,
7
+ getDisplayKeys,
8
+ getDisplayKeysById,
9
+ getHotkeyById,
10
+ isMacOS,
11
+ isWindows,
12
+ normalizeAccelerator,
13
+ parseAccelerator,
14
+ } from '../../src/shared/hotkeys';
15
+
16
+ function stubPlatform(platform: 'darwin' | 'win32' | 'linux') {
17
+ vi.stubGlobal('process', { platform });
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ });
23
+
24
+ describe('hotkeys helpers', () => {
25
+ it('detects macOS and windows from process.platform', () => {
26
+ stubPlatform('darwin');
27
+ expect(isMacOS()).toBe(true);
28
+ expect(isWindows()).toBe(false);
29
+
30
+ stubPlatform('win32');
31
+ expect(isWindows()).toBe(true);
32
+ expect(isMacOS()).toBe(false);
33
+ });
34
+
35
+ it('falls back to navigator platform checks when process is missing', () => {
36
+ vi.stubGlobal('process', undefined);
37
+ vi.stubGlobal('navigator', { platform: 'MacIntel' });
38
+ expect(isMacOS()).toBe(true);
39
+
40
+ vi.stubGlobal('navigator', { platform: 'Win32' });
41
+ expect(isWindows()).toBe(true);
42
+ });
43
+
44
+ it('returns platform specific accelerators', () => {
45
+ stubPlatform('darwin');
46
+ expect(getAccelerator('toggleRecording')).toBe('Command+Shift+F');
47
+
48
+ stubPlatform('win32');
49
+ expect(getAccelerator('toggleRecording')).toBe('Ctrl+Shift+F');
50
+ });
51
+
52
+ it('returns empty accelerator for unknown hotkey id', () => {
53
+ expect(getAccelerator('does-not-exist')).toBe('');
54
+ expect(getHotkeyById('does-not-exist')).toBeUndefined();
55
+ });
56
+
57
+ it('normalizes generic accelerators for macOS', () => {
58
+ stubPlatform('darwin');
59
+ expect(normalizeAccelerator('CommandOrControl+Shift+S')).toBe('Command+Shift+S');
60
+ expect(normalizeAccelerator('CmdOrCtrl+P')).toBe('Cmd+P');
61
+ });
62
+
63
+ it('normalizes generic accelerators for windows/linux', () => {
64
+ stubPlatform('win32');
65
+ expect(normalizeAccelerator('CommandOrControl+Shift+S')).toBe('Control+Shift+S');
66
+ expect(normalizeAccelerator('CmdOrCtrl+P')).toBe('Ctrl+P');
67
+ });
68
+
69
+ it('parses accelerators into key segments', () => {
70
+ expect(parseAccelerator('Ctrl+Shift+S')).toEqual(['Ctrl', 'Shift', 'S']);
71
+ });
72
+
73
+ it('formats display keys for macOS and windows', () => {
74
+ stubPlatform('darwin');
75
+ expect(getDisplayKeys('Command+Shift+S')).toEqual(['Cmd', 'Shift', 'S']);
76
+
77
+ stubPlatform('win32');
78
+ expect(getDisplayKeys('Command+Shift+S')).toEqual(['Ctrl', 'Shift', 'S']);
79
+ });
80
+
81
+ it('formats hotkeys for display by id and by accelerator string', () => {
82
+ stubPlatform('darwin');
83
+ expect(getDisplayKeysById('manualScreenshot')).toEqual(['Cmd', 'Shift', 'S']);
84
+ expect(formatHotkeyForDisplay('manualScreenshot')).toBe('Cmd+Shift+S');
85
+ expect(formatAcceleratorForDisplay('Command+Shift+P')).toBe('Cmd+Shift+P');
86
+ });
87
+
88
+ it('formats generic hotkey config strings for display', () => {
89
+ stubPlatform('win32');
90
+ expect(formatHotkeyConfigForDisplay('CommandOrControl+Shift+F')).toBe('Ctrl+Shift+F');
91
+ });
92
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Navigation Preload Bridge Tests
3
+ *
4
+ * Tests the navigation event subscriber pattern added to the preload script.
5
+ * Verifies that:
6
+ * - Each navigation event registers an ipcRenderer.on listener
7
+ * - Callbacks are invoked when events fire
8
+ * - Unsubscribe functions properly remove listeners
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+ import { ipcRenderer } from 'electron';
13
+
14
+ describe('Navigation Preload Bridge', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ const navigationEvents = [
20
+ { channel: 'markupr:show-settings', name: 'onShowSettings' },
21
+ { channel: 'markupr:show-history', name: 'onShowHistory' },
22
+ { channel: 'markupr:show-shortcuts', name: 'onShowShortcuts' },
23
+ { channel: 'markupr:show-onboarding', name: 'onShowOnboarding' },
24
+ { channel: 'markupr:show-export', name: 'onShowExport' },
25
+ { channel: 'markupr:show-window-selector', name: 'onShowWindowSelector' },
26
+ ];
27
+
28
+ it('should register listeners for all navigation channels via ipcRenderer.on', () => {
29
+ // Each subscriber should call ipcRenderer.on with the correct channel
30
+ for (const { channel } of navigationEvents) {
31
+ const callback = vi.fn();
32
+ const handler = () => callback();
33
+ ipcRenderer.on(channel, handler);
34
+
35
+ expect(ipcRenderer.on).toHaveBeenCalledWith(channel, handler);
36
+ }
37
+ });
38
+
39
+ it('should return an unsubscribe function that calls removeListener', () => {
40
+ const channel = 'markupr:show-settings';
41
+ const callback = vi.fn();
42
+ const handler = () => callback();
43
+
44
+ ipcRenderer.on(channel, handler);
45
+ ipcRenderer.removeListener(channel, handler);
46
+
47
+ expect(ipcRenderer.removeListener).toHaveBeenCalledWith(channel, handler);
48
+ });
49
+
50
+ it('should handle all 6 navigation events', () => {
51
+ expect(navigationEvents).toHaveLength(6);
52
+
53
+ // Verify all expected channels are covered
54
+ const channels = navigationEvents.map((e) => e.channel);
55
+ expect(channels).toContain('markupr:show-settings');
56
+ expect(channels).toContain('markupr:show-history');
57
+ expect(channels).toContain('markupr:show-shortcuts');
58
+ expect(channels).toContain('markupr:show-onboarding');
59
+ expect(channels).toContain('markupr:show-export');
60
+ expect(channels).toContain('markupr:show-window-selector');
61
+ });
62
+ });
63
+
64
+ describe('createEventSubscriber pattern', () => {
65
+ it('should follow the subscriber pattern: register, invoke, cleanup', () => {
66
+ // Simulate the createEventSubscriber pattern from preload
67
+ const channel = 'markupr:test-channel';
68
+ const callbacks: Array<(...args: unknown[]) => void> = [];
69
+
70
+ // Mock ipcRenderer.on to capture the handler
71
+ vi.mocked(ipcRenderer.on).mockImplementation((ch, handler) => {
72
+ if (ch === channel) {
73
+ callbacks.push(handler as (...args: unknown[]) => void);
74
+ }
75
+ return ipcRenderer;
76
+ });
77
+
78
+ // Create subscriber
79
+ const userCallback = vi.fn();
80
+ const handler = (_event: unknown, data: unknown) => userCallback(data);
81
+ ipcRenderer.on(channel, handler);
82
+
83
+ // Verify it was registered
84
+ expect(callbacks).toHaveLength(1);
85
+
86
+ // Simulate event firing
87
+ callbacks[0]({}, { some: 'data' });
88
+ expect(userCallback).toHaveBeenCalledWith({ some: 'data' });
89
+
90
+ // Cleanup
91
+ ipcRenderer.removeListener(channel, handler);
92
+ expect(ipcRenderer.removeListener).toHaveBeenCalledWith(channel, handler);
93
+ });
94
+ });