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,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Flow Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the full session lifecycle with mocked services:
|
|
5
|
+
* - Complete recording session flow
|
|
6
|
+
* - Transcript to screenshot matching
|
|
7
|
+
* - Feedback item creation
|
|
8
|
+
* - Output generation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { EventEmitter } from 'events';
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Mocks for Integration Testing
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
// Mock electron-store
|
|
19
|
+
vi.mock('electron-store', () => ({
|
|
20
|
+
default: vi.fn().mockImplementation(() => ({
|
|
21
|
+
get: vi.fn((key: string, defaultValue?: unknown) => defaultValue),
|
|
22
|
+
set: vi.fn(),
|
|
23
|
+
delete: vi.fn(),
|
|
24
|
+
clear: vi.fn(),
|
|
25
|
+
})),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock electron
|
|
29
|
+
vi.mock('electron', () => ({
|
|
30
|
+
BrowserWindow: vi.fn().mockImplementation(() => ({
|
|
31
|
+
webContents: { send: vi.fn() },
|
|
32
|
+
isDestroyed: vi.fn(() => false),
|
|
33
|
+
destroy: vi.fn(),
|
|
34
|
+
})),
|
|
35
|
+
app: {
|
|
36
|
+
getPath: vi.fn(() => '/tmp'),
|
|
37
|
+
getName: vi.fn(() => 'markupr'),
|
|
38
|
+
getVersion: vi.fn(() => '0.4.0'),
|
|
39
|
+
},
|
|
40
|
+
clipboard: {
|
|
41
|
+
writeText: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
Notification: {
|
|
44
|
+
isSupported: vi.fn(() => false),
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Integrated Test Controller
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
interface TranscriptResult {
|
|
53
|
+
text: string;
|
|
54
|
+
isFinal: boolean;
|
|
55
|
+
confidence: number;
|
|
56
|
+
timestamp: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Screenshot {
|
|
60
|
+
id: string;
|
|
61
|
+
buffer: Buffer;
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
timestamp: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface FeedbackItem {
|
|
68
|
+
id: string;
|
|
69
|
+
timestamp: number;
|
|
70
|
+
text: string;
|
|
71
|
+
screenshot?: Screenshot;
|
|
72
|
+
confidence: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Integration test harness that simulates the full session flow
|
|
77
|
+
*/
|
|
78
|
+
class IntegrationTestHarness {
|
|
79
|
+
private state: 'idle' | 'recording' | 'processing' | 'complete' = 'idle';
|
|
80
|
+
private session: {
|
|
81
|
+
id: string;
|
|
82
|
+
startTime: number;
|
|
83
|
+
endTime?: number;
|
|
84
|
+
feedbackItems: FeedbackItem[];
|
|
85
|
+
transcriptBuffer: TranscriptResult[];
|
|
86
|
+
screenshotBuffer: Screenshot[];
|
|
87
|
+
} | null = null;
|
|
88
|
+
|
|
89
|
+
private events = new EventEmitter();
|
|
90
|
+
private pendingScreenshots: Screenshot[] = [];
|
|
91
|
+
private readonly MATCH_WINDOW_MS = 3000;
|
|
92
|
+
|
|
93
|
+
// Service mock events
|
|
94
|
+
public transcriptionEvents = new EventEmitter();
|
|
95
|
+
public captureEvents = new EventEmitter();
|
|
96
|
+
|
|
97
|
+
constructor() {
|
|
98
|
+
// Set up internal event handlers
|
|
99
|
+
this.transcriptionEvents.on('transcript', (result: TranscriptResult) => {
|
|
100
|
+
this.handleTranscript(result);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.transcriptionEvents.on('utteranceEnd', (_timestamp: number) => {
|
|
104
|
+
this.handleUtteranceEnd();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Public API
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async start(sourceId: string): Promise<void> {
|
|
113
|
+
if (this.state !== 'idle') {
|
|
114
|
+
throw new Error(`Cannot start from state: ${this.state}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.session = {
|
|
118
|
+
id: `session-${Date.now()}`,
|
|
119
|
+
startTime: Date.now(),
|
|
120
|
+
feedbackItems: [],
|
|
121
|
+
transcriptBuffer: [],
|
|
122
|
+
screenshotBuffer: [],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
this.state = 'recording';
|
|
126
|
+
this.events.emit('stateChange', this.state);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async stop(): Promise<typeof this.session> {
|
|
130
|
+
if (this.state !== 'recording') {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.state = 'processing';
|
|
135
|
+
|
|
136
|
+
// Process any remaining pending screenshots
|
|
137
|
+
this.processPendingScreenshots();
|
|
138
|
+
|
|
139
|
+
if (this.session) {
|
|
140
|
+
this.session.endTime = Date.now();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.state = 'complete';
|
|
144
|
+
this.events.emit('stateChange', this.state);
|
|
145
|
+
|
|
146
|
+
return this.session ? { ...this.session } : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
reset(): void {
|
|
150
|
+
this.state = 'idle';
|
|
151
|
+
this.session = null;
|
|
152
|
+
this.pendingScreenshots = [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getState() {
|
|
156
|
+
return this.state;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getSession() {
|
|
160
|
+
return this.session;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
onStateChange(callback: (state: string) => void): () => void {
|
|
164
|
+
this.events.on('stateChange', callback);
|
|
165
|
+
return () => this.events.off('stateChange', callback);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onFeedbackItem(callback: (item: FeedbackItem) => void): () => void {
|
|
169
|
+
this.events.on('feedbackItem', callback);
|
|
170
|
+
return () => this.events.off('feedbackItem', callback);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Simulate Events (for testing)
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
simulateTranscript(text: string, options: Partial<TranscriptResult> = {}): void {
|
|
178
|
+
const result: TranscriptResult = {
|
|
179
|
+
text,
|
|
180
|
+
isFinal: options.isFinal ?? true,
|
|
181
|
+
confidence: options.confidence ?? 0.95,
|
|
182
|
+
timestamp: options.timestamp ?? Date.now() / 1000,
|
|
183
|
+
};
|
|
184
|
+
this.transcriptionEvents.emit('transcript', result);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
simulateUtteranceEnd(): void {
|
|
188
|
+
this.transcriptionEvents.emit('utteranceEnd', Date.now() / 1000);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
simulateScreenshot(): Screenshot {
|
|
192
|
+
const screenshot: Screenshot = {
|
|
193
|
+
id: `ss-${Date.now()}`,
|
|
194
|
+
buffer: Buffer.from('mock-image'),
|
|
195
|
+
width: 1920,
|
|
196
|
+
height: 1080,
|
|
197
|
+
timestamp: Date.now(),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (this.session) {
|
|
201
|
+
this.session.screenshotBuffer.push(screenshot);
|
|
202
|
+
this.pendingScreenshots.push(screenshot);
|
|
203
|
+
this.tryMatchScreenshotToTranscript(screenshot);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return screenshot;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Internal Handlers
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
private handleTranscript(result: TranscriptResult): void {
|
|
214
|
+
if (!this.session || this.state !== 'recording') {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.session.transcriptBuffer.push(result);
|
|
219
|
+
|
|
220
|
+
if (result.isFinal) {
|
|
221
|
+
this.tryMatchTranscriptToScreenshot(result);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private handleUtteranceEnd(): void {
|
|
226
|
+
if (!this.session || this.state !== 'recording') {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Utterance end triggers screenshot capture
|
|
231
|
+
this.simulateScreenshot();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private tryMatchScreenshotToTranscript(screenshot: Screenshot): void {
|
|
235
|
+
if (!this.session) return;
|
|
236
|
+
|
|
237
|
+
const screenshotTimeSec = screenshot.timestamp / 1000;
|
|
238
|
+
const recentTranscripts = this.session.transcriptBuffer.filter(
|
|
239
|
+
(t) => t.isFinal && screenshotTimeSec - t.timestamp < this.MATCH_WINDOW_MS / 1000
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (recentTranscripts.length > 0) {
|
|
243
|
+
const combinedText = recentTranscripts.map((t) => t.text).join(' ').trim();
|
|
244
|
+
const avgConfidence =
|
|
245
|
+
recentTranscripts.reduce((sum, t) => sum + t.confidence, 0) / recentTranscripts.length;
|
|
246
|
+
|
|
247
|
+
const feedbackItem: FeedbackItem = {
|
|
248
|
+
id: `fb-${Date.now()}`,
|
|
249
|
+
timestamp: screenshot.timestamp,
|
|
250
|
+
text: combinedText,
|
|
251
|
+
screenshot,
|
|
252
|
+
confidence: avgConfidence,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
this.session.feedbackItems.push(feedbackItem);
|
|
256
|
+
this.events.emit('feedbackItem', feedbackItem);
|
|
257
|
+
|
|
258
|
+
// Remove from pending
|
|
259
|
+
const idx = this.pendingScreenshots.findIndex((s) => s.id === screenshot.id);
|
|
260
|
+
if (idx !== -1) {
|
|
261
|
+
this.pendingScreenshots.splice(idx, 1);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private tryMatchTranscriptToScreenshot(result: TranscriptResult): void {
|
|
267
|
+
if (!this.session || this.pendingScreenshots.length === 0) return;
|
|
268
|
+
|
|
269
|
+
const resultTimeMs = result.timestamp * 1000;
|
|
270
|
+
|
|
271
|
+
for (const screenshot of [...this.pendingScreenshots]) {
|
|
272
|
+
if (screenshot.timestamp - resultTimeMs < this.MATCH_WINDOW_MS && screenshot.timestamp >= resultTimeMs) {
|
|
273
|
+
const screenshotTimeSec = screenshot.timestamp / 1000;
|
|
274
|
+
const windowTranscripts = this.session.transcriptBuffer.filter(
|
|
275
|
+
(t) =>
|
|
276
|
+
t.isFinal &&
|
|
277
|
+
screenshotTimeSec - t.timestamp < this.MATCH_WINDOW_MS / 1000 &&
|
|
278
|
+
screenshotTimeSec >= t.timestamp
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const combinedText = windowTranscripts.map((t) => t.text).join(' ').trim();
|
|
282
|
+
|
|
283
|
+
if (combinedText) {
|
|
284
|
+
const avgConfidence =
|
|
285
|
+
windowTranscripts.reduce((sum, t) => sum + t.confidence, 0) / windowTranscripts.length;
|
|
286
|
+
|
|
287
|
+
const feedbackItem: FeedbackItem = {
|
|
288
|
+
id: `fb-${Date.now()}`,
|
|
289
|
+
timestamp: screenshot.timestamp,
|
|
290
|
+
text: combinedText,
|
|
291
|
+
screenshot,
|
|
292
|
+
confidence: avgConfidence,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
this.session.feedbackItems.push(feedbackItem);
|
|
296
|
+
this.events.emit('feedbackItem', feedbackItem);
|
|
297
|
+
|
|
298
|
+
// Remove from pending
|
|
299
|
+
const idx = this.pendingScreenshots.findIndex((s) => s.id === screenshot.id);
|
|
300
|
+
if (idx !== -1) {
|
|
301
|
+
this.pendingScreenshots.splice(idx, 1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private processPendingScreenshots(): void {
|
|
309
|
+
if (!this.session) return;
|
|
310
|
+
|
|
311
|
+
for (const screenshot of this.pendingScreenshots) {
|
|
312
|
+
const screenshotTimeSec = screenshot.timestamp / 1000;
|
|
313
|
+
const nearbyTranscripts = this.session.transcriptBuffer.filter(
|
|
314
|
+
(t) => t.isFinal && Math.abs(screenshotTimeSec - t.timestamp) < (this.MATCH_WINDOW_MS * 2) / 1000
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const combinedText = nearbyTranscripts.map((t) => t.text).join(' ').trim();
|
|
318
|
+
|
|
319
|
+
const feedbackItem: FeedbackItem = {
|
|
320
|
+
id: `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
321
|
+
timestamp: screenshot.timestamp,
|
|
322
|
+
text: combinedText || '[No matching narration]',
|
|
323
|
+
screenshot,
|
|
324
|
+
confidence: nearbyTranscripts.length > 0
|
|
325
|
+
? nearbyTranscripts.reduce((sum, t) => sum + t.confidence, 0) / nearbyTranscripts.length
|
|
326
|
+
: 0,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
this.session.feedbackItems.push(feedbackItem);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
this.pendingScreenshots = [];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// Tests
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
describe('Session Flow Integration', () => {
|
|
341
|
+
let harness: IntegrationTestHarness;
|
|
342
|
+
|
|
343
|
+
beforeEach(() => {
|
|
344
|
+
harness = new IntegrationTestHarness();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
afterEach(() => {
|
|
348
|
+
harness.reset();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('Complete Session Flow', () => {
|
|
352
|
+
it('should complete a full recording session', async () => {
|
|
353
|
+
// Start session
|
|
354
|
+
await harness.start('screen:0:0');
|
|
355
|
+
expect(harness.getState()).toBe('recording');
|
|
356
|
+
|
|
357
|
+
// Simulate some transcription
|
|
358
|
+
harness.simulateTranscript('The save button is broken.');
|
|
359
|
+
harness.simulateUtteranceEnd();
|
|
360
|
+
|
|
361
|
+
// Simulate more transcription
|
|
362
|
+
harness.simulateTranscript('It does not respond to clicks.');
|
|
363
|
+
harness.simulateUtteranceEnd();
|
|
364
|
+
|
|
365
|
+
// Stop session
|
|
366
|
+
const session = await harness.stop();
|
|
367
|
+
|
|
368
|
+
expect(harness.getState()).toBe('complete');
|
|
369
|
+
expect(session).not.toBeNull();
|
|
370
|
+
expect(session!.feedbackItems.length).toBeGreaterThan(0);
|
|
371
|
+
expect(session!.endTime).toBeDefined();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should track state changes throughout session', async () => {
|
|
375
|
+
const stateChanges: string[] = [];
|
|
376
|
+
harness.onStateChange((state) => stateChanges.push(state));
|
|
377
|
+
|
|
378
|
+
await harness.start('screen:0:0');
|
|
379
|
+
await harness.stop();
|
|
380
|
+
|
|
381
|
+
expect(stateChanges).toContain('recording');
|
|
382
|
+
expect(stateChanges).toContain('complete');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should emit feedback items as they are created', async () => {
|
|
386
|
+
const feedbackItems: FeedbackItem[] = [];
|
|
387
|
+
harness.onFeedbackItem((item) => feedbackItems.push(item));
|
|
388
|
+
|
|
389
|
+
await harness.start('screen:0:0');
|
|
390
|
+
|
|
391
|
+
// Simulate feedback creation
|
|
392
|
+
harness.simulateTranscript('First piece of feedback.');
|
|
393
|
+
harness.simulateUtteranceEnd();
|
|
394
|
+
|
|
395
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
396
|
+
|
|
397
|
+
expect(feedbackItems.length).toBeGreaterThan(0);
|
|
398
|
+
expect(feedbackItems[0].text).toContain('First piece of feedback');
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('Transcript to Screenshot Matching', () => {
|
|
403
|
+
it('should match screenshot to recent transcript', async () => {
|
|
404
|
+
await harness.start('screen:0:0');
|
|
405
|
+
|
|
406
|
+
// Transcript first
|
|
407
|
+
harness.simulateTranscript('Testing the UI flow.');
|
|
408
|
+
|
|
409
|
+
// Then screenshot (triggered by utterance end)
|
|
410
|
+
harness.simulateUtteranceEnd();
|
|
411
|
+
|
|
412
|
+
const session = await harness.stop();
|
|
413
|
+
|
|
414
|
+
// Should have created a feedback item
|
|
415
|
+
expect(session!.feedbackItems.length).toBe(1);
|
|
416
|
+
expect(session!.feedbackItems[0].text).toContain('Testing the UI flow');
|
|
417
|
+
expect(session!.feedbackItems[0].screenshot).toBeDefined();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should combine multiple transcripts for single screenshot', async () => {
|
|
421
|
+
await harness.start('screen:0:0');
|
|
422
|
+
|
|
423
|
+
const now = Date.now() / 1000;
|
|
424
|
+
|
|
425
|
+
// Multiple transcripts in quick succession
|
|
426
|
+
harness.simulateTranscript('First part of feedback.', { timestamp: now });
|
|
427
|
+
harness.simulateTranscript('Second part continues.', { timestamp: now + 0.5 });
|
|
428
|
+
harness.simulateTranscript('Third part wraps up.', { timestamp: now + 1 });
|
|
429
|
+
|
|
430
|
+
// Then utterance end triggers screenshot
|
|
431
|
+
harness.simulateUtteranceEnd();
|
|
432
|
+
|
|
433
|
+
const session = await harness.stop();
|
|
434
|
+
|
|
435
|
+
// Should combine into one feedback item
|
|
436
|
+
expect(session!.feedbackItems.length).toBe(1);
|
|
437
|
+
expect(session!.feedbackItems[0].text).toContain('First part');
|
|
438
|
+
expect(session!.feedbackItems[0].text).toContain('Second part');
|
|
439
|
+
expect(session!.feedbackItems[0].text).toContain('Third part');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should calculate average confidence from matched transcripts', async () => {
|
|
443
|
+
await harness.start('screen:0:0');
|
|
444
|
+
|
|
445
|
+
const now = Date.now() / 1000;
|
|
446
|
+
|
|
447
|
+
harness.simulateTranscript('High confidence.', { timestamp: now, confidence: 0.98 });
|
|
448
|
+
harness.simulateTranscript('Lower confidence.', { timestamp: now + 0.5, confidence: 0.82 });
|
|
449
|
+
|
|
450
|
+
harness.simulateUtteranceEnd();
|
|
451
|
+
|
|
452
|
+
const session = await harness.stop();
|
|
453
|
+
|
|
454
|
+
// Average of 0.98 and 0.82 = 0.90
|
|
455
|
+
expect(session!.feedbackItems[0].confidence).toBeCloseTo(0.9, 1);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should handle screenshots with no matching transcript', async () => {
|
|
459
|
+
await harness.start('screen:0:0');
|
|
460
|
+
|
|
461
|
+
// Screenshot without any transcript
|
|
462
|
+
harness.simulateScreenshot();
|
|
463
|
+
|
|
464
|
+
const session = await harness.stop();
|
|
465
|
+
|
|
466
|
+
// Should still create feedback item (with placeholder text)
|
|
467
|
+
expect(session!.feedbackItems.length).toBe(1);
|
|
468
|
+
expect(session!.feedbackItems[0].text).toBe('[No matching narration]');
|
|
469
|
+
expect(session!.feedbackItems[0].confidence).toBe(0);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe('Session Data Integrity', () => {
|
|
474
|
+
it('should preserve all screenshots in buffer', async () => {
|
|
475
|
+
await harness.start('screen:0:0');
|
|
476
|
+
|
|
477
|
+
// Multiple utterance ends = multiple screenshots
|
|
478
|
+
harness.simulateTranscript('First');
|
|
479
|
+
harness.simulateUtteranceEnd();
|
|
480
|
+
|
|
481
|
+
harness.simulateTranscript('Second');
|
|
482
|
+
harness.simulateUtteranceEnd();
|
|
483
|
+
|
|
484
|
+
harness.simulateTranscript('Third');
|
|
485
|
+
harness.simulateUtteranceEnd();
|
|
486
|
+
|
|
487
|
+
const session = await harness.stop();
|
|
488
|
+
|
|
489
|
+
expect(session!.screenshotBuffer.length).toBe(3);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('should preserve all transcripts in buffer', async () => {
|
|
493
|
+
await harness.start('screen:0:0');
|
|
494
|
+
|
|
495
|
+
harness.simulateTranscript('First transcript', { isFinal: true });
|
|
496
|
+
harness.simulateTranscript('Interim text', { isFinal: false });
|
|
497
|
+
harness.simulateTranscript('Second transcript', { isFinal: true });
|
|
498
|
+
harness.simulateTranscript('Third transcript', { isFinal: true });
|
|
499
|
+
|
|
500
|
+
const session = await harness.stop();
|
|
501
|
+
|
|
502
|
+
expect(session!.transcriptBuffer.length).toBe(4);
|
|
503
|
+
expect(session!.transcriptBuffer.filter((t) => t.isFinal).length).toBe(3);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should set correct timestamps', async () => {
|
|
507
|
+
const startTime = Date.now();
|
|
508
|
+
|
|
509
|
+
await harness.start('screen:0:0');
|
|
510
|
+
|
|
511
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
512
|
+
|
|
513
|
+
harness.simulateTranscript('Test');
|
|
514
|
+
harness.simulateUtteranceEnd();
|
|
515
|
+
|
|
516
|
+
const session = await harness.stop();
|
|
517
|
+
|
|
518
|
+
expect(session!.startTime).toBeGreaterThanOrEqual(startTime);
|
|
519
|
+
expect(session!.endTime).toBeGreaterThan(session!.startTime);
|
|
520
|
+
expect(session!.feedbackItems[0].timestamp).toBeGreaterThanOrEqual(session!.startTime);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe('Edge Cases', () => {
|
|
525
|
+
it('should handle empty session (no transcripts)', async () => {
|
|
526
|
+
await harness.start('screen:0:0');
|
|
527
|
+
|
|
528
|
+
const session = await harness.stop();
|
|
529
|
+
|
|
530
|
+
expect(session).not.toBeNull();
|
|
531
|
+
expect(session!.feedbackItems).toHaveLength(0);
|
|
532
|
+
expect(session!.transcriptBuffer).toHaveLength(0);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should handle rapid utterance ends', async () => {
|
|
536
|
+
await harness.start('screen:0:0');
|
|
537
|
+
|
|
538
|
+
// Rapid fire utterance ends
|
|
539
|
+
harness.simulateTranscript('Quick');
|
|
540
|
+
harness.simulateUtteranceEnd();
|
|
541
|
+
harness.simulateUtteranceEnd();
|
|
542
|
+
harness.simulateUtteranceEnd();
|
|
543
|
+
|
|
544
|
+
const session = await harness.stop();
|
|
545
|
+
|
|
546
|
+
// Should have multiple screenshots (no debounce in this simple test harness)
|
|
547
|
+
expect(session!.screenshotBuffer.length).toBe(3);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should not accept events after stop', async () => {
|
|
551
|
+
await harness.start('screen:0:0');
|
|
552
|
+
await harness.stop();
|
|
553
|
+
|
|
554
|
+
// These should be ignored
|
|
555
|
+
harness.simulateTranscript('Late transcript');
|
|
556
|
+
harness.simulateUtteranceEnd();
|
|
557
|
+
|
|
558
|
+
const session = harness.getSession();
|
|
559
|
+
|
|
560
|
+
// Session should not have the late transcript
|
|
561
|
+
expect(session!.transcriptBuffer).not.toContainEqual(
|
|
562
|
+
expect.objectContaining({ text: 'Late transcript' })
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should handle start after reset', async () => {
|
|
567
|
+
await harness.start('screen:0:0');
|
|
568
|
+
harness.simulateTranscript('First session');
|
|
569
|
+
await harness.stop();
|
|
570
|
+
|
|
571
|
+
harness.reset();
|
|
572
|
+
|
|
573
|
+
await harness.start('screen:0:0');
|
|
574
|
+
harness.simulateTranscript('Second session');
|
|
575
|
+
|
|
576
|
+
const session = await harness.stop();
|
|
577
|
+
|
|
578
|
+
// Should only have second session content
|
|
579
|
+
expect(session!.transcriptBuffer.length).toBe(1);
|
|
580
|
+
expect(session!.transcriptBuffer[0].text).toBe('Second session');
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
});
|