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.
- package/.claude/commands/review-feedback.md +47 -0
- package/.eslintrc.json +35 -0
- package/.github/CODEOWNERS +16 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
- package/.github/dependabot.yml +70 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/deploy-landing.yml +134 -0
- package/.github/workflows/nightly.yml +288 -0
- package/.github/workflows/release.yml +318 -0
- package/CHANGELOG.md +127 -0
- package/CLAUDE.md +137 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +390 -0
- package/LICENSE +21 -0
- package/PRODUCT_VISION.md +277 -0
- package/README.md +517 -0
- package/SECURITY.md +51 -0
- package/SIGNING_INSTRUCTIONS.md +284 -0
- package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
- package/assets/svg-source/dmg-background.svg +70 -0
- package/assets/svg-source/icon.svg +20 -0
- package/assets/svg-source/tray-icon-processing.svg +7 -0
- package/assets/svg-source/tray-icon-recording.svg +7 -0
- package/assets/svg-source/tray-icon.svg +6 -0
- package/assets/tray-complete.png +0 -0
- package/assets/tray-complete@2x.png +0 -0
- package/assets/tray-completeTemplate.png +0 -0
- package/assets/tray-completeTemplate@2x.png +0 -0
- package/assets/tray-error.png +0 -0
- package/assets/tray-error@2x.png +0 -0
- package/assets/tray-errorTemplate.png +0 -0
- package/assets/tray-errorTemplate@2x.png +0 -0
- package/assets/tray-icon-processing.png +0 -0
- package/assets/tray-icon-processing@2x.png +0 -0
- package/assets/tray-icon-processingTemplate.png +0 -0
- package/assets/tray-icon-processingTemplate@2x.png +0 -0
- package/assets/tray-icon-recording.png +0 -0
- package/assets/tray-icon-recording@2x.png +0 -0
- package/assets/tray-icon-recordingTemplate.png +0 -0
- package/assets/tray-icon-recordingTemplate@2x.png +0 -0
- package/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/assets/tray-iconTemplate.png +0 -0
- package/assets/tray-iconTemplate@2x.png +0 -0
- package/assets/tray-idle.png +0 -0
- package/assets/tray-idle@2x.png +0 -0
- package/assets/tray-idleTemplate.png +0 -0
- package/assets/tray-idleTemplate@2x.png +0 -0
- package/assets/tray-processing-0.png +0 -0
- package/assets/tray-processing-0@2x.png +0 -0
- package/assets/tray-processing-0Template.png +0 -0
- package/assets/tray-processing-0Template@2x.png +0 -0
- package/assets/tray-processing-1.png +0 -0
- package/assets/tray-processing-1@2x.png +0 -0
- package/assets/tray-processing-1Template.png +0 -0
- package/assets/tray-processing-1Template@2x.png +0 -0
- package/assets/tray-processing-2.png +0 -0
- package/assets/tray-processing-2@2x.png +0 -0
- package/assets/tray-processing-2Template.png +0 -0
- package/assets/tray-processing-2Template@2x.png +0 -0
- package/assets/tray-processing-3.png +0 -0
- package/assets/tray-processing-3@2x.png +0 -0
- package/assets/tray-processing-3Template.png +0 -0
- package/assets/tray-processing-3Template@2x.png +0 -0
- package/assets/tray-processing.png +0 -0
- package/assets/tray-processing@2x.png +0 -0
- package/assets/tray-processingTemplate.png +0 -0
- package/assets/tray-processingTemplate@2x.png +0 -0
- package/assets/tray-recording.png +0 -0
- package/assets/tray-recording@2x.png +0 -0
- package/assets/tray-recordingTemplate.png +0 -0
- package/assets/tray-recordingTemplate@2x.png +0 -0
- package/build/DMG_BACKGROUND_SPEC.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/dmg-background@2x.png +0 -0
- package/build/entitlements.mac.inherit.plist +27 -0
- package/build/entitlements.mac.plist +41 -0
- package/build/favicon-16.png +0 -0
- package/build/favicon-180.png +0 -0
- package/build/favicon-192.png +0 -0
- package/build/favicon-32.png +0 -0
- package/build/favicon-48.png +0 -0
- package/build/favicon-512.png +0 -0
- package/build/favicon-64.png +0 -0
- package/build/icon-128.png +0 -0
- package/build/icon-16.png +0 -0
- package/build/icon-24.png +0 -0
- package/build/icon-256.png +0 -0
- package/build/icon-32.png +0 -0
- package/build/icon-48.png +0 -0
- package/build/icon-64.png +0 -0
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.iconset/icon_128x128.png +0 -0
- package/build/icon.iconset/icon_128x128@2x.png +0 -0
- package/build/icon.iconset/icon_16x16.png +0 -0
- package/build/icon.iconset/icon_16x16@2x.png +0 -0
- package/build/icon.iconset/icon_256x256.png +0 -0
- package/build/icon.iconset/icon_256x256@2x.png +0 -0
- package/build/icon.iconset/icon_32x32.png +0 -0
- package/build/icon.iconset/icon_32x32@2x.png +0 -0
- package/build/icon.iconset/icon_512x512.png +0 -0
- package/build/icon.iconset/icon_512x512@2x.png +0 -0
- package/build/icon.png +0 -0
- package/build/installer-header.bmp +0 -0
- package/build/installer-header.png +0 -0
- package/build/installer-sidebar.bmp +0 -0
- package/build/installer-sidebar.png +0 -0
- package/build/installer.nsh +45 -0
- package/build/overlay-processing.png +0 -0
- package/build/overlay-recording.png +0 -0
- package/build/toolbar-record.png +0 -0
- package/build/toolbar-screenshot.png +0 -0
- package/build/toolbar-settings.png +0 -0
- package/build/toolbar-stop.png +0 -0
- package/dist/main/index.mjs +12612 -0
- package/dist/preload/index.mjs +907 -0
- package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
- package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
- package/dist/renderer/index.html +27 -0
- package/docs/AI_AGENT_QUICKSTART.md +42 -0
- package/docs/AI_PIPELINE_DESIGN.md +595 -0
- package/docs/API.md +514 -0
- package/docs/ARCHITECTURE.md +460 -0
- package/docs/CONFIGURATION.md +336 -0
- package/docs/DEVELOPMENT.md +508 -0
- package/docs/EXPORT_FORMATS.md +451 -0
- package/docs/GETTING_STARTED.md +236 -0
- package/docs/KEYBOARD_SHORTCUTS.md +334 -0
- package/docs/TROUBLESHOOTING.md +418 -0
- package/docs/landing/index.html +672 -0
- package/docs/landing/script.js +342 -0
- package/docs/landing/styles.css +1543 -0
- package/electron-builder.yml +140 -0
- package/electron.vite.config.ts +63 -0
- package/package.json +108 -0
- package/railway.json +12 -0
- package/scripts/build.mjs +51 -0
- package/scripts/generate-icons.mjs +314 -0
- package/scripts/generate-installer-images.cjs +253 -0
- package/scripts/generate-tray-icons.mjs +258 -0
- package/scripts/notarize.cjs +180 -0
- package/scripts/one-click-clean-test.sh +147 -0
- package/scripts/postinstall.mjs +36 -0
- package/scripts/setup-markupr.sh +55 -0
- package/setup +17 -0
- package/site/index.html +1835 -0
- package/site/package.json +11 -0
- package/site/railway.json +12 -0
- package/site/server.js +31 -0
- package/src/main/AutoUpdater.ts +392 -0
- package/src/main/CrashRecovery.ts +655 -0
- package/src/main/ErrorHandler.ts +703 -0
- package/src/main/HotkeyManager.ts +399 -0
- package/src/main/MenuManager.ts +529 -0
- package/src/main/PermissionManager.ts +420 -0
- package/src/main/SessionController.ts +1465 -0
- package/src/main/TrayManager.ts +540 -0
- package/src/main/ai/AIPipelineManager.ts +199 -0
- package/src/main/ai/ClaudeAnalyzer.ts +339 -0
- package/src/main/ai/ImageOptimizer.ts +176 -0
- package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
- package/src/main/ai/index.ts +16 -0
- package/src/main/ai/types.ts +258 -0
- package/src/main/analysis/ClarificationGenerator.ts +385 -0
- package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
- package/src/main/analysis/index.ts +19 -0
- package/src/main/audio/AudioCapture.ts +978 -0
- package/src/main/audio/audioUtils.ts +100 -0
- package/src/main/audio/index.ts +20 -0
- package/src/main/capture/index.ts +1 -0
- package/src/main/index.ts +1693 -0
- package/src/main/ipc/captureHandlers.ts +272 -0
- package/src/main/ipc/index.ts +45 -0
- package/src/main/ipc/outputHandlers.ts +302 -0
- package/src/main/ipc/sessionHandlers.ts +56 -0
- package/src/main/ipc/settingsHandlers.ts +471 -0
- package/src/main/ipc/types.ts +56 -0
- package/src/main/ipc/windowHandlers.ts +277 -0
- package/src/main/output/ClipboardService.ts +369 -0
- package/src/main/output/ExportService.ts +539 -0
- package/src/main/output/FileManager.ts +416 -0
- package/src/main/output/MarkdownGenerator.ts +791 -0
- package/src/main/output/MarkdownPatcher.ts +299 -0
- package/src/main/output/index.ts +186 -0
- package/src/main/output/sessionAdapter.ts +207 -0
- package/src/main/output/templates/html-template.ts +553 -0
- package/src/main/pipeline/FrameExtractor.ts +330 -0
- package/src/main/pipeline/PostProcessor.ts +399 -0
- package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
- package/src/main/pipeline/index.ts +36 -0
- package/src/main/platform/WindowsTaskbar.ts +600 -0
- package/src/main/platform/index.ts +16 -0
- package/src/main/settings/SettingsManager.ts +730 -0
- package/src/main/settings/index.ts +19 -0
- package/src/main/transcription/ModelDownloadManager.ts +494 -0
- package/src/main/transcription/TierManager.ts +219 -0
- package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
- package/src/main/transcription/WhisperService.ts +748 -0
- package/src/main/transcription/index.ts +56 -0
- package/src/main/transcription/types.ts +135 -0
- package/src/main/windows/PopoverManager.ts +284 -0
- package/src/main/windows/TaskbarIntegration.ts +452 -0
- package/src/main/windows/index.ts +23 -0
- package/src/preload/index.ts +1047 -0
- package/src/renderer/App.tsx +515 -0
- package/src/renderer/AppWrapper.tsx +28 -0
- package/src/renderer/assets/logo-dark.svg +7 -0
- package/src/renderer/assets/logo.svg +7 -0
- package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
- package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
- package/src/renderer/components/AnnotationOverlay.tsx +836 -0
- package/src/renderer/components/AudioWaveform.tsx +811 -0
- package/src/renderer/components/ClarificationQuestions.tsx +656 -0
- package/src/renderer/components/CountdownTimer.tsx +495 -0
- package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
- package/src/renderer/components/DonateButton.tsx +127 -0
- package/src/renderer/components/ErrorBoundary.tsx +308 -0
- package/src/renderer/components/ExportDialog.tsx +872 -0
- package/src/renderer/components/HotkeyHint.tsx +261 -0
- package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
- package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
- package/src/renderer/components/Onboarding.tsx +1830 -0
- package/src/renderer/components/ProcessingOverlay.tsx +157 -0
- package/src/renderer/components/RecordingOverlay.tsx +423 -0
- package/src/renderer/components/SessionHistory.tsx +1746 -0
- package/src/renderer/components/SessionReview.tsx +1321 -0
- package/src/renderer/components/SettingsPanel.tsx +217 -0
- package/src/renderer/components/Skeleton.tsx +347 -0
- package/src/renderer/components/StatusIndicator.tsx +86 -0
- package/src/renderer/components/ThemeProvider.tsx +429 -0
- package/src/renderer/components/Tooltip.tsx +370 -0
- package/src/renderer/components/TranscriptionPreview.tsx +183 -0
- package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
- package/src/renderer/components/UpdateNotification.tsx +377 -0
- package/src/renderer/components/WindowSelector.tsx +947 -0
- package/src/renderer/components/index.ts +99 -0
- package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
- package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
- package/src/renderer/components/primitives/DangerButton.tsx +45 -0
- package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
- package/src/renderer/components/primitives/Dropdown.tsx +34 -0
- package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
- package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
- package/src/renderer/components/primitives/Slider.tsx +43 -0
- package/src/renderer/components/primitives/Toggle.tsx +36 -0
- package/src/renderer/components/primitives/index.ts +10 -0
- package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
- package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
- package/src/renderer/components/settings/GeneralTab.tsx +40 -0
- package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
- package/src/renderer/components/settings/RecordingTab.tsx +84 -0
- package/src/renderer/components/settings/index.ts +9 -0
- package/src/renderer/components/settings/settingsStyles.ts +673 -0
- package/src/renderer/components/settings/tabConfig.tsx +85 -0
- package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
- package/src/renderer/contexts/ProcessingContext.tsx +227 -0
- package/src/renderer/contexts/RecordingContext.tsx +683 -0
- package/src/renderer/contexts/UIContext.tsx +326 -0
- package/src/renderer/contexts/index.ts +24 -0
- package/src/renderer/donateMessages.ts +69 -0
- package/src/renderer/hooks/index.ts +75 -0
- package/src/renderer/hooks/useAnimation.tsx +544 -0
- package/src/renderer/hooks/useTheme.ts +313 -0
- package/src/renderer/index.html +26 -0
- package/src/renderer/main.tsx +52 -0
- package/src/renderer/styles/animations.css +1093 -0
- package/src/renderer/styles/app-shell.css +662 -0
- package/src/renderer/styles/globals.css +515 -0
- package/src/renderer/styles/theme.ts +578 -0
- package/src/renderer/types/electron.d.ts +385 -0
- package/src/shared/hotkeys.ts +283 -0
- package/src/shared/types.ts +809 -0
- package/tests/clipboard.test.ts +228 -0
- package/tests/e2e/criticalPaths.test.ts +594 -0
- package/tests/feedbackAnalyzer.test.ts +303 -0
- package/tests/integration/sessionFlow.test.ts +583 -0
- package/tests/markdownGenerator.test.ts +418 -0
- package/tests/output.test.ts +96 -0
- package/tests/setup.ts +486 -0
- package/tests/unit/appIntegration.test.ts +676 -0
- package/tests/unit/appViewState.test.ts +281 -0
- package/tests/unit/audioIpcChannels.test.ts +17 -0
- package/tests/unit/exportService.test.ts +492 -0
- package/tests/unit/hotkeys.test.ts +92 -0
- package/tests/unit/navigationPreload.test.ts +94 -0
- package/tests/unit/onboardingFlow.test.ts +345 -0
- package/tests/unit/permissionManager.test.ts +175 -0
- package/tests/unit/permissionManagerExpanded.test.ts +296 -0
- package/tests/unit/screenRecordingRenderer.test.ts +368 -0
- package/tests/unit/sessionController.test.ts +515 -0
- package/tests/unit/tierManager.test.ts +61 -0
- package/tests/unit/tierManagerExpanded.test.ts +142 -0
- package/tests/unit/transcriptAnalyzer.test.ts +64 -0
- package/tsconfig.json +25 -0
- 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
|
+
});
|