@hasna/recordings 0.2.14 → 0.3.2
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/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2094 -280
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +19 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -50
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +141 -57
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +188 -80
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +109 -50
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +192 -31
- package/scripts/macos_artifact.ts +323 -43
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/smoke_macos_app.sh +62 -16
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
- package/src/native/Recordings/App/RecordingsApp.swift +37 -1
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +226 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
// MARK: - Realtime settlement benchmark (opt-in, network-bound)
|
|
6
|
+
|
|
7
|
+
/// Measures how long real OpenAI realtime sessions take to settle after release —
|
|
8
|
+
/// the number the settlement budget in `RecordingEngine` is calibrated against.
|
|
9
|
+
///
|
|
10
|
+
/// This is a live-network benchmark, not a unit test: it opens real realtime
|
|
11
|
+
/// transcription sessions with the machine's configured API key and paces real PCM
|
|
12
|
+
/// through them exactly like the capture pipeline does (4,800-byte chunks every
|
|
13
|
+
/// 100 ms, a periodic commit every 900 ms). It never writes to the production
|
|
14
|
+
/// recordings home — client logs go to a temporary directory.
|
|
15
|
+
///
|
|
16
|
+
/// Disabled unless RECORDINGS_BENCH_REALTIME=1. Configuration:
|
|
17
|
+
/// RECORDINGS_BENCH_WAVS colon-separated 24 kHz/16-bit/mono WAV paths (required)
|
|
18
|
+
/// RECORDINGS_BENCH_RUNS runs per WAV (default 5)
|
|
19
|
+
/// RECORDINGS_BENCH_FINISH_BUDGET_MS finish budget passed to the client (default 10000,
|
|
20
|
+
/// deliberately far above production budgets so the
|
|
21
|
+
/// observed wait is the true settle latency)
|
|
22
|
+
/// RECORDINGS_BENCH_HOME home directory the API key is resolved under; the
|
|
23
|
+
/// key store reads <home>/.hasna/recordings/config.json
|
|
24
|
+
/// (default: the user home)
|
|
25
|
+
///
|
|
26
|
+
/// Results are emitted as BENCH_RESULT lines on stdout, one per run.
|
|
27
|
+
struct RealtimeSettleBenchmark {
|
|
28
|
+
private static var enabled: Bool {
|
|
29
|
+
ProcessInfo.processInfo.environment["RECORDINGS_BENCH_REALTIME"] == "1"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Test("realtime settle latency", .enabled(if: RealtimeSettleBenchmark.enabled))
|
|
33
|
+
@MainActor
|
|
34
|
+
func settleLatency() async throws {
|
|
35
|
+
let environment = ProcessInfo.processInfo.environment
|
|
36
|
+
let wavPaths = (environment["RECORDINGS_BENCH_WAVS"] ?? "")
|
|
37
|
+
.split(separator: ":").map(String.init)
|
|
38
|
+
try #require(!wavPaths.isEmpty, "RECORDINGS_BENCH_WAVS is required")
|
|
39
|
+
let runsPerWav = Int(environment["RECORDINGS_BENCH_RUNS"] ?? "") ?? 5
|
|
40
|
+
let finishBudget = UInt64(environment["RECORDINGS_BENCH_FINISH_BUDGET_MS"] ?? "") ?? 10_000
|
|
41
|
+
// OpenAIAPIKeyStore appends .hasna/recordings/config.json to the home it is
|
|
42
|
+
// given, so this must be the user home itself — not the recordings directory.
|
|
43
|
+
let keyHome = environment["RECORDINGS_BENCH_HOME"] ?? NSHomeDirectory()
|
|
44
|
+
|
|
45
|
+
let apiKey = OpenAIAPIKeyStore.load(homePath: keyHome)
|
|
46
|
+
try #require(!apiKey.isEmpty, "no API key available from \(keyHome)")
|
|
47
|
+
let language = OpenAIAPIKeyStore.apiLanguageHint(
|
|
48
|
+
for: OpenAIAPIKeyStore.loadLanguage(homePath: keyHome)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Client logs must never land in the production Recordings.log.
|
|
52
|
+
let benchHome = NSTemporaryDirectory() + "recordings-settle-bench-" + UUID().uuidString
|
|
53
|
+
try FileManager.default.createDirectory(
|
|
54
|
+
atPath: benchHome, withIntermediateDirectories: true
|
|
55
|
+
)
|
|
56
|
+
print("BENCH_HOME \(benchHome)")
|
|
57
|
+
|
|
58
|
+
var coldRun = true
|
|
59
|
+
for wavPath in wavPaths {
|
|
60
|
+
let pcm = try Self.pcmData(fromWavAtPath: wavPath)
|
|
61
|
+
let audioSeconds = Double(pcm.count) / 48_000.0
|
|
62
|
+
for run in 1...runsPerWav {
|
|
63
|
+
let result = await Self.streamOnce(
|
|
64
|
+
pcm: pcm,
|
|
65
|
+
apiKey: apiKey,
|
|
66
|
+
language: language,
|
|
67
|
+
homePath: benchHome,
|
|
68
|
+
finishBudgetMilliseconds: finishBudget
|
|
69
|
+
)
|
|
70
|
+
let errorField = result.error?
|
|
71
|
+
.replacingOccurrences(of: " ", with: "_") ?? ""
|
|
72
|
+
print(
|
|
73
|
+
"BENCH_RESULT wav=\(wavPath) run=\(run) cold=\(coldRun) "
|
|
74
|
+
+ "audio_seconds=\(String(format: "%.1f", audioSeconds)) "
|
|
75
|
+
+ "pcm_bytes=\(pcm.count) budget_ms=\(finishBudget) "
|
|
76
|
+
+ "stream_start_ms=\(result.streamStartMilliseconds) "
|
|
77
|
+
+ "finish_wait_ms=\(result.finishWaitMilliseconds) "
|
|
78
|
+
+ "settled=\(result.settled) chars=\(result.textCount) "
|
|
79
|
+
+ "error=\(errorField)"
|
|
80
|
+
)
|
|
81
|
+
coldRun = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private struct RunResult {
|
|
87
|
+
let streamStartMilliseconds: UInt64
|
|
88
|
+
let finishWaitMilliseconds: UInt64
|
|
89
|
+
let settled: Bool
|
|
90
|
+
let textCount: Int
|
|
91
|
+
let error: String?
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Streams one WAV through a fresh client at capture pace and measures the
|
|
95
|
+
/// finish/settle wait. A fresh client per run mirrors production: every
|
|
96
|
+
/// recording builds a new WebSocket session.
|
|
97
|
+
@MainActor
|
|
98
|
+
private static func streamOnce(
|
|
99
|
+
pcm: Data,
|
|
100
|
+
apiKey: String,
|
|
101
|
+
language: String,
|
|
102
|
+
homePath: String,
|
|
103
|
+
finishBudgetMilliseconds: UInt64
|
|
104
|
+
) async -> RunResult {
|
|
105
|
+
let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: homePath)
|
|
106
|
+
|
|
107
|
+
let startBegan = monotonicMilliseconds()
|
|
108
|
+
await client.startStreaming(language: language)
|
|
109
|
+
let streamStart = monotonicMilliseconds() - startBegan
|
|
110
|
+
guard client.isStreaming else {
|
|
111
|
+
let text = client.stop()
|
|
112
|
+
return RunResult(
|
|
113
|
+
streamStartMilliseconds: streamStart,
|
|
114
|
+
finishWaitMilliseconds: 0,
|
|
115
|
+
settled: false,
|
|
116
|
+
textCount: text.count,
|
|
117
|
+
error: client.error ?? "failed to start streaming"
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Capture pacing: 4,800 bytes per 100 ms tick (24 kHz * 2 bytes / 10),
|
|
122
|
+
// periodic commit every 900 ms — the same cadence RecordingEngine uses.
|
|
123
|
+
let chunkSize = 4_800
|
|
124
|
+
var offset = 0
|
|
125
|
+
var lastCommitAt = monotonicMilliseconds()
|
|
126
|
+
while offset < pcm.count {
|
|
127
|
+
let end = min(offset + chunkSize, pcm.count)
|
|
128
|
+
client.sendAudio(pcm.subdata(in: offset..<end))
|
|
129
|
+
offset = end
|
|
130
|
+
try? await Task.sleep(for: .milliseconds(100))
|
|
131
|
+
let now = monotonicMilliseconds()
|
|
132
|
+
if now - lastCommitAt >= 900 {
|
|
133
|
+
if await client.commitInput(reason: "periodic") {
|
|
134
|
+
lastCommitAt = now
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let finishBegan = monotonicMilliseconds()
|
|
140
|
+
let finishResult = await client.finish(
|
|
141
|
+
timeoutMilliseconds: finishBudgetMilliseconds,
|
|
142
|
+
pipelineID: UUID().uuidString,
|
|
143
|
+
pipelineStartedUptimeMilliseconds: finishBegan
|
|
144
|
+
)
|
|
145
|
+
let finishWait = monotonicMilliseconds() - finishBegan
|
|
146
|
+
return RunResult(
|
|
147
|
+
streamStartMilliseconds: streamStart,
|
|
148
|
+
finishWaitMilliseconds: finishWait,
|
|
149
|
+
settled: finishResult.settled,
|
|
150
|
+
textCount: finishResult.text.count,
|
|
151
|
+
error: finishResult.error
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Extracts raw PCM from a canonical RIFF/WAVE file, asserting the capture
|
|
156
|
+
/// pipeline's format (PCM, mono, 24 kHz, 16-bit) so a mispacked fixture cannot
|
|
157
|
+
/// silently skew the measurement.
|
|
158
|
+
private static func pcmData(fromWavAtPath path: String) throws -> Data {
|
|
159
|
+
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
160
|
+
try #require(data.count > 44, "WAV too small: \(path)")
|
|
161
|
+
try #require(
|
|
162
|
+
String(data: data[0..<4], encoding: .ascii) == "RIFF"
|
|
163
|
+
&& String(data: data[8..<12], encoding: .ascii) == "WAVE",
|
|
164
|
+
"not a RIFF/WAVE file: \(path)"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
var index = 12
|
|
168
|
+
var pcm: Data?
|
|
169
|
+
while index + 8 <= data.count {
|
|
170
|
+
let chunkID = String(data: data[index..<index + 4], encoding: .ascii) ?? ""
|
|
171
|
+
let chunkSize = Int(
|
|
172
|
+
UInt32(data[index + 4]) | UInt32(data[index + 5]) << 8
|
|
173
|
+
| UInt32(data[index + 6]) << 16 | UInt32(data[index + 7]) << 24
|
|
174
|
+
)
|
|
175
|
+
let body = index + 8
|
|
176
|
+
guard body + chunkSize <= data.count else { break }
|
|
177
|
+
if chunkID == "fmt ", chunkSize >= 16 {
|
|
178
|
+
let format = UInt16(data[body]) | UInt16(data[body + 1]) << 8
|
|
179
|
+
let channels = UInt16(data[body + 2]) | UInt16(data[body + 3]) << 8
|
|
180
|
+
let sampleRate = UInt32(data[body + 4]) | UInt32(data[body + 5]) << 8
|
|
181
|
+
| UInt32(data[body + 6]) << 16 | UInt32(data[body + 7]) << 24
|
|
182
|
+
let bits = UInt16(data[body + 14]) | UInt16(data[body + 15]) << 8
|
|
183
|
+
try #require(
|
|
184
|
+
format == 1 && channels == 1 && sampleRate == 24_000 && bits == 16,
|
|
185
|
+
"bench WAV must be PCM/mono/24kHz/16-bit: \(path)"
|
|
186
|
+
)
|
|
187
|
+
} else if chunkID == "data" {
|
|
188
|
+
pcm = data.subdata(in: body..<body + chunkSize)
|
|
189
|
+
}
|
|
190
|
+
// Chunks are word-aligned: odd sizes carry one padding byte.
|
|
191
|
+
index = body + chunkSize + (chunkSize % 2)
|
|
192
|
+
}
|
|
193
|
+
let payload = try #require(pcm, "no data chunk in \(path)")
|
|
194
|
+
return payload
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private static func monotonicMilliseconds() -> UInt64 {
|
|
198
|
+
UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
// MARK: - Realtime settlement budget
|
|
6
|
+
|
|
7
|
+
struct RealtimeSettleBudgetTests {
|
|
8
|
+
/// Capture byte rate the budget converts from: 24 kHz, 16-bit, mono.
|
|
9
|
+
private let bytesPerSecond = 48_000
|
|
10
|
+
|
|
11
|
+
@Test("empty and pathological captures get the floor budget")
|
|
12
|
+
func floorBudget() {
|
|
13
|
+
#expect(RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 0) == 1_500)
|
|
14
|
+
#expect(RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: -1) == 1_500)
|
|
15
|
+
#expect(RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 1) == 1_500)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Test("the budget grows with recording length because the fallback cost does")
|
|
19
|
+
func scalesWithAudioSeconds() {
|
|
20
|
+
// A ~5 s dictation: the old fixed 700 ms budget was routinely missed by real
|
|
21
|
+
// settle times (0.6-1.6 s measured); the scaled budget stays comfortably above.
|
|
22
|
+
#expect(
|
|
23
|
+
RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 5 * bytesPerSecond)
|
|
24
|
+
== 1_500 + 5 * 25
|
|
25
|
+
)
|
|
26
|
+
// A one-minute recording is worth a 3 s settlement wait: its fallback pass
|
|
27
|
+
// costs ~19 s (measured).
|
|
28
|
+
#expect(
|
|
29
|
+
RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 60 * bytesPerSecond)
|
|
30
|
+
== 3_000
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test("very long recordings cap at the ceiling instead of stalling the user")
|
|
35
|
+
func ceilingBudget() {
|
|
36
|
+
// The 4m07s dictation from the field report: uncapped scaling would ask for
|
|
37
|
+
// 1_500 + 247*25 = 7_675 ms; the ceiling keeps the wait bounded.
|
|
38
|
+
#expect(
|
|
39
|
+
RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 247 * bytesPerSecond)
|
|
40
|
+
== 5_000
|
|
41
|
+
)
|
|
42
|
+
#expect(
|
|
43
|
+
RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: Int.max) == 5_000
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test("partial seconds are rounded down, never up past the ceiling")
|
|
48
|
+
func partialSeconds() {
|
|
49
|
+
// 1.9 s of audio counts as 1 whole second of budget scaling.
|
|
50
|
+
let bytes = Int(Double(bytesPerSecond) * 1.9)
|
|
51
|
+
#expect(
|
|
52
|
+
RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: bytes) == 1_525
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// MARK: - Realtime outbound send deadlines
|
|
58
|
+
|
|
59
|
+
struct RealtimeSendDeadlineTests {
|
|
60
|
+
@Test("the configure send absorbs a cold connection handshake")
|
|
61
|
+
func configureDeadlineCoversColdHandshake() {
|
|
62
|
+
// Cold DNS + TCP + TLS + WebSocket upgrade measured 0.77-1.0 s against the
|
|
63
|
+
// live endpoint from a fresh process; the configure deadline must clear it
|
|
64
|
+
// with margin or a cold connection poisons the whole session.
|
|
65
|
+
#expect(RealtimeTranscriptionClient.configureSendTimeoutMilliseconds >= 2_000)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test("post-configure sends tolerate jitter without outliving the configure budget")
|
|
69
|
+
func steadyStateDeadlineToleratesJitter() {
|
|
70
|
+
// One send past the deadline permanently vetoes the fast path, so the
|
|
71
|
+
// deadline must clear ordinary jitter (a 500 ms value poisoned 3/5 of
|
|
72
|
+
// two-minute live sessions) while staying under the configure budget —
|
|
73
|
+
// the bounded outbound queue, not this deadline, is the stall detector.
|
|
74
|
+
#expect(RealtimeTranscriptionClient.outboundSendTimeoutMilliseconds >= 2_000)
|
|
75
|
+
#expect(
|
|
76
|
+
RealtimeTranscriptionClient.outboundSendTimeoutMilliseconds
|
|
77
|
+
< RealtimeTranscriptionClient.configureSendTimeoutMilliseconds
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -428,6 +428,21 @@ struct RealtimeTranscriptionTests {
|
|
|
428
428
|
) == "final words")
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
@Test("A settled transcript survives an unrelated server error")
|
|
432
|
+
func settledTranscriptSurvivesIncidentalServerError() {
|
|
433
|
+
let finishResult = RealtimeFinishResult(
|
|
434
|
+
text: "complete settled transcript",
|
|
435
|
+
settled: true,
|
|
436
|
+
error: "server_error: unrelated event"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
#expect(RecordingEngine.settledRealtimeFallbackTranscript(
|
|
440
|
+
finishResult: finishResult,
|
|
441
|
+
pcmByteCount: 96_000,
|
|
442
|
+
language: "en"
|
|
443
|
+
) == "complete settled transcript")
|
|
444
|
+
}
|
|
445
|
+
|
|
431
446
|
@Test("Settlement deadline arithmetic saturates at UInt64 bounds")
|
|
432
447
|
@MainActor
|
|
433
448
|
func settlementDeadlineDoesNotOverflow() async {
|
|
@@ -560,19 +575,22 @@ struct RealtimeTranscriptionTests {
|
|
|
560
575
|
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
561
576
|
transcript: "settled realtime text",
|
|
562
577
|
hasSelection: false,
|
|
563
|
-
intentDetectionEnabled: true
|
|
578
|
+
intentDetectionEnabled: true,
|
|
579
|
+
enhanceTriggersJSON: "[]"
|
|
564
580
|
))
|
|
565
581
|
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
566
582
|
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
567
583
|
transcript: "rewrite this to be more formal",
|
|
568
584
|
hasSelection: true,
|
|
569
|
-
intentDetectionEnabled: true
|
|
585
|
+
intentDetectionEnabled: true,
|
|
586
|
+
enhanceTriggersJSON: "[]"
|
|
570
587
|
) == false)
|
|
571
588
|
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
572
589
|
postProcessingMode: PostProcessingMode.always.rawValue,
|
|
573
590
|
transcript: "settled realtime text",
|
|
574
591
|
hasSelection: false,
|
|
575
|
-
intentDetectionEnabled: true
|
|
592
|
+
intentDetectionEnabled: true,
|
|
593
|
+
enhanceTriggersJSON: "[]"
|
|
576
594
|
) == false)
|
|
577
595
|
#expect(RecordingEngine.shouldLabelRewriting(
|
|
578
596
|
postProcessingMode: PostProcessingMode.always.rawValue
|
|
@@ -666,7 +684,7 @@ struct RealtimeTranscriptionTests {
|
|
|
666
684
|
@Test("Persistence revision advances only for confirmed saves")
|
|
667
685
|
@MainActor
|
|
668
686
|
func persistenceRevisionTracksConfirmedSaves() {
|
|
669
|
-
let engine = RecordingEngine()
|
|
687
|
+
let engine = RecordingEngine(homePath: makeIsolatedTestHome("realtime-tests"))
|
|
670
688
|
|
|
671
689
|
#expect(engine.persistedRecordingRevision == 0)
|
|
672
690
|
engine.recordPersistenceCompletion(savedText: nil)
|
|
@@ -67,7 +67,7 @@ private func makeEngine(
|
|
|
67
67
|
accessibilityTrusted: Bool = true,
|
|
68
68
|
pasteRecorder: PasteRecorder? = nil
|
|
69
69
|
) -> RecordingEngine {
|
|
70
|
-
let engine = RecordingEngine()
|
|
70
|
+
let engine = RecordingEngine(homePath: makeIsolatedTestHome("delivery-tests"))
|
|
71
71
|
engine.openAIAPIKeyProvider = { "" }
|
|
72
72
|
engine.microphoneAuthorization = { .denied }
|
|
73
73
|
engine.accessibilityTrustCheck = { accessibilityTrusted }
|
|
@@ -295,7 +295,7 @@ struct VoiceShortcutRoutingTests {
|
|
|
295
295
|
func shortcutBoundaries() async {
|
|
296
296
|
let recorder = PasteRecorder()
|
|
297
297
|
let engine = makeEngine(pasteRecorder: recorder)
|
|
298
|
-
let shortcuts = VoiceShortcuts()
|
|
298
|
+
let shortcuts = VoiceShortcuts(homePath: makeIsolatedTestHome("voice-shortcuts"))
|
|
299
299
|
shortcuts.shortcuts = [VoiceShortcut(trigger: "add disclaimer", content: "This is not legal advice.")]
|
|
300
300
|
engine.voiceShortcuts = shortcuts
|
|
301
301
|
|
|
@@ -534,7 +534,7 @@ struct PasteSettlementObservationTests {
|
|
|
534
534
|
let coordinator = engine.installPasteCoordinatorForTesting(
|
|
535
535
|
schedule: { _, operation in scheduled.append(operation) },
|
|
536
536
|
writeAndVerify: { _ in PasteboardWriteResult(verified: true, ownershipChangeCount: 7) },
|
|
537
|
-
postPaste: {
|
|
537
|
+
postPaste: { .posted }
|
|
538
538
|
)
|
|
539
539
|
var observations = 0
|
|
540
540
|
let subscription = engine.objectWillChange.sink { _ in observations += 1 }
|
|
@@ -562,8 +562,10 @@ struct PasteSettlementObservationTests {
|
|
|
562
562
|
#expect(engine.canStartRecording)
|
|
563
563
|
let presentation = MenuBarPresentation(
|
|
564
564
|
isRecording: engine.isRecording,
|
|
565
|
+
isWarmingUpCapture: engine.isWarmingUpCapture,
|
|
565
566
|
canStartRecording: engine.canStartRecording,
|
|
566
|
-
statusMessage: "Ready"
|
|
567
|
+
statusMessage: "Ready",
|
|
568
|
+
blockedReason: nil
|
|
567
569
|
)
|
|
568
570
|
#expect(presentation.iconName == "mic.fill")
|
|
569
571
|
#expect(presentation.primaryActionEnabled, "Start must re-enable from the settlement event alone")
|
|
@@ -716,8 +718,10 @@ struct MenuBarEngineContractTests {
|
|
|
716
718
|
#expect(!engine.canStartRecording)
|
|
717
719
|
let busy = MenuBarPresentation(
|
|
718
720
|
isRecording: engine.isRecording,
|
|
721
|
+
isWarmingUpCapture: engine.isWarmingUpCapture,
|
|
719
722
|
canStartRecording: engine.canStartRecording,
|
|
720
|
-
statusMessage: engine.statusMessage
|
|
723
|
+
statusMessage: engine.statusMessage,
|
|
724
|
+
blockedReason: nil
|
|
721
725
|
)
|
|
722
726
|
#expect(busy.iconName == "ellipsis.circle")
|
|
723
727
|
#expect(!busy.primaryActionEnabled)
|
|
@@ -732,8 +736,10 @@ struct MenuBarEngineContractTests {
|
|
|
732
736
|
_ = await waitUntil { engine.canStartRecording }
|
|
733
737
|
let idle = MenuBarPresentation(
|
|
734
738
|
isRecording: engine.isRecording,
|
|
739
|
+
isWarmingUpCapture: engine.isWarmingUpCapture,
|
|
735
740
|
canStartRecording: engine.canStartRecording,
|
|
736
|
-
statusMessage: "Ready"
|
|
741
|
+
statusMessage: "Ready",
|
|
742
|
+
blockedReason: nil
|
|
737
743
|
)
|
|
738
744
|
#expect(idle.iconName == "mic.fill")
|
|
739
745
|
#expect(idle.primaryActionEnabled)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
/// Locks the `homePath` seam that keeps a test run out of the operator's real
|
|
6
|
+
/// `~/.hasna/recordings`. `RecordingEngine.init` already creates the audio spool and logs
|
|
7
|
+
/// before it returns, so the seam is only worth anything if it is honoured that early —
|
|
8
|
+
/// these tests assert against artifacts `init` itself produced.
|
|
9
|
+
@MainActor
|
|
10
|
+
struct RecordingEngineHomeIsolationTests {
|
|
11
|
+
@Test("init writes its log line and audio spool under the injected home, not the real one")
|
|
12
|
+
func initArtifactsLandUnderInjectedHome() throws {
|
|
13
|
+
let home = makeIsolatedTestHome("home-isolation")
|
|
14
|
+
|
|
15
|
+
let engine = RecordingEngine(homePath: home)
|
|
16
|
+
|
|
17
|
+
#expect(engine.home == home)
|
|
18
|
+
#expect(FileManager.default.fileExists(atPath: "\(home)/.hasna/recordings/audio"))
|
|
19
|
+
|
|
20
|
+
// Reading the line back proves the seam reaches NativeAppLog rather than merely
|
|
21
|
+
// being stored: an unthreaded `home` would leave this file absent here and append
|
|
22
|
+
// to the real log instead.
|
|
23
|
+
let logged = try String(
|
|
24
|
+
contentsOfFile: "\(home)/.hasna/recordings/Recordings.log",
|
|
25
|
+
encoding: .utf8
|
|
26
|
+
)
|
|
27
|
+
#expect(logged.contains("RecordingEngine init"))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test("every log write the engine makes is addressed to the injected home")
|
|
31
|
+
func engineLogWritesStayUnderInjectedHome() throws {
|
|
32
|
+
let first = makeIsolatedTestHome("home-isolation-first")
|
|
33
|
+
let second = makeIsolatedTestHome("home-isolation-second")
|
|
34
|
+
|
|
35
|
+
_ = RecordingEngine(homePath: first)
|
|
36
|
+
_ = RecordingEngine(homePath: second)
|
|
37
|
+
|
|
38
|
+
// Two engines with two homes must not cross-write. A shared or process-global home
|
|
39
|
+
// would put both init lines in one file and leave the other empty or missing.
|
|
40
|
+
for home in [first, second] {
|
|
41
|
+
let logged = try String(
|
|
42
|
+
contentsOfFile: "\(home)/.hasna/recordings/Recordings.log",
|
|
43
|
+
encoding: .utf8
|
|
44
|
+
)
|
|
45
|
+
#expect(logged.components(separatedBy: "RecordingEngine init").count - 1 == 1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The engine is not the only type that roots itself at the live home. `VoiceShortcuts`
|
|
50
|
+
/// persists `voice-shortcuts.json` the same way, so a test that adds a shortcut used to
|
|
51
|
+
/// rewrite the operator's real file. Ported from the same seam work; the structural half of
|
|
52
|
+
/// this rule is enforced in `src/__tests__/native-test-log-isolation-contract.test.ts`,
|
|
53
|
+
/// which — unlike this file — can actually run without a Swift toolchain.
|
|
54
|
+
@Test("voice shortcuts persist under the injected home, not the live one")
|
|
55
|
+
func voiceShortcutsPersistUnderInjectedHome() throws {
|
|
56
|
+
let home = makeIsolatedTestHome("home-isolation-shortcuts")
|
|
57
|
+
|
|
58
|
+
let shortcuts = VoiceShortcuts(homePath: home)
|
|
59
|
+
shortcuts.add(trigger: "containment probe", content: "written to a temp home")
|
|
60
|
+
|
|
61
|
+
let text = try String(
|
|
62
|
+
contentsOfFile: "\(home)/.hasna/recordings/voice-shortcuts.json",
|
|
63
|
+
encoding: .utf8
|
|
64
|
+
)
|
|
65
|
+
#expect(text.contains("containment probe"))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -219,19 +219,28 @@ struct RecordingStartGateTests {
|
|
|
219
219
|
|
|
220
220
|
@Test("recording cannot begin while already recording or transcribing")
|
|
221
221
|
func cannotBeginWhenBusy() {
|
|
222
|
-
#expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false) == true)
|
|
223
|
-
#expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false) == false)
|
|
224
|
-
#expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true) == false)
|
|
222
|
+
#expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false, isWarmingUpCapture: false) == true)
|
|
223
|
+
#expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false, isWarmingUpCapture: false) == false)
|
|
224
|
+
#expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true, isWarmingUpCapture: false) == false)
|
|
225
225
|
#expect(RecordingEngine.canBeginRecording(
|
|
226
226
|
isRecording: false,
|
|
227
227
|
isTranscribing: false,
|
|
228
|
+
isWarmingUpCapture: false,
|
|
228
229
|
isAwaitingMicrophonePermission: true
|
|
229
230
|
) == false)
|
|
230
231
|
#expect(RecordingEngine.canBeginRecording(
|
|
231
232
|
isRecording: false,
|
|
232
233
|
isTranscribing: false,
|
|
234
|
+
isWarmingUpCapture: false,
|
|
233
235
|
isDeliveryPending: true
|
|
234
236
|
) == false)
|
|
237
|
+
// The warm-up window: `isRecording` is false because no audio has arrived, but the
|
|
238
|
+
// microphone is open and a second start would open a second recorder on top of it.
|
|
239
|
+
#expect(RecordingEngine.canBeginRecording(
|
|
240
|
+
isRecording: false,
|
|
241
|
+
isTranscribing: false,
|
|
242
|
+
isWarmingUpCapture: true
|
|
243
|
+
) == false)
|
|
235
244
|
}
|
|
236
245
|
|
|
237
246
|
@Test("recording status is derived from the trigger")
|