@hasna/recordings 0.3.13 → 0.5.0
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/README.md +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- 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 +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.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 +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/src/native/Recordings/App/SidebarView.swift +0 -212
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import Testing
|
|
4
|
+
@testable import RecordingsLib
|
|
5
|
+
|
|
6
|
+
private final class ProviderTestRecorder: PCMRecordingSource, @unchecked Sendable {
|
|
7
|
+
private let lock = NSLock()
|
|
8
|
+
private var callback: (@Sendable (Data) -> Void)?
|
|
9
|
+
private let stopPCM: Data
|
|
10
|
+
private(set) var stopped = false
|
|
11
|
+
init(stopPCM: Data = Data()) { self.stopPCM = stopPCM }
|
|
12
|
+
func attach(_ callback: @escaping @Sendable (Data) -> Void) { lock.withLock { self.callback = callback } }
|
|
13
|
+
func emit(_ data: Data) { lock.withLock { callback }?(data) }
|
|
14
|
+
func start() throws {}
|
|
15
|
+
func stop() {
|
|
16
|
+
let delivery = lock.withLock { () -> (@Sendable (Data) -> Void)? in
|
|
17
|
+
guard !stopped else { return nil }
|
|
18
|
+
stopped = true
|
|
19
|
+
return callback
|
|
20
|
+
}
|
|
21
|
+
if !stopPCM.isEmpty { delivery?(stopPCM) }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private final class ProviderTestSession: RecordingTranscriptionSession, @unchecked Sendable {
|
|
26
|
+
private let lock = NSLock()
|
|
27
|
+
private var audio = Data()
|
|
28
|
+
private var packets: [Data] = []
|
|
29
|
+
private var request: RecordingTranscriptionRequest?
|
|
30
|
+
private var partial: (@Sendable (String) -> Void)?
|
|
31
|
+
private var cancelled = false
|
|
32
|
+
private var inputEndings: [Data] = []
|
|
33
|
+
private var gate: CheckedContinuation<RecordingProviderResult, Never>?
|
|
34
|
+
let delayFinish: Bool
|
|
35
|
+
|
|
36
|
+
init(delayFinish: Bool = false) { self.delayFinish = delayFinish }
|
|
37
|
+
var snapshot: (Data, RecordingTranscriptionRequest?, Bool) { lock.withLock { (audio, request, cancelled) } }
|
|
38
|
+
var endedPCM: [Data] { lock.withLock { inputEndings } }
|
|
39
|
+
var receivedPackets: [Data] { lock.withLock { packets } }
|
|
40
|
+
func configure(_ partial: @escaping @Sendable (String) -> Void) { lock.withLock { self.partial = partial } }
|
|
41
|
+
func emitPartial(_ text: String) { lock.withLock { partial }?(text) }
|
|
42
|
+
func appendPCM(_ data: Data) {
|
|
43
|
+
lock.withLock { if !cancelled { audio.append(data); packets.append(data) } }
|
|
44
|
+
}
|
|
45
|
+
func inputEnded() { lock.withLock { if !cancelled { inputEndings.append(audio) } } }
|
|
46
|
+
func finish(_ request: RecordingTranscriptionRequest) async throws -> RecordingProviderResult {
|
|
47
|
+
if delayFinish {
|
|
48
|
+
return await withCheckedContinuation { continuation in
|
|
49
|
+
lock.withLock { self.request = request; gate = continuation }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lock.withLock { self.request = request }
|
|
53
|
+
return RecordingProviderResult(rawText: "spoken words", processedText: "Spoken words.")
|
|
54
|
+
}
|
|
55
|
+
func cancel() { lock.withLock { cancelled = true } }
|
|
56
|
+
func resolve() {
|
|
57
|
+
let continuation = lock.withLock { let value = gate; gate = nil; return value }
|
|
58
|
+
continuation?.resume(returning: RecordingProviderResult(rawText: "late result"))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private struct ProviderTestAdapter: RecordingTranscriptionProvider {
|
|
63
|
+
let session: ProviderTestSession
|
|
64
|
+
func makeSession(configuration: RecordingProviderSessionConfiguration, onPartialTranscript: @escaping @Sendable (String) -> Void) throws -> any RecordingTranscriptionSession {
|
|
65
|
+
#expect(configuration.sampleRate == 24_000)
|
|
66
|
+
#expect(configuration.channels == 1)
|
|
67
|
+
#expect(configuration.bitsPerSample == 16)
|
|
68
|
+
session.configure(onPartialTranscript)
|
|
69
|
+
return session
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@MainActor
|
|
74
|
+
struct RecordingProviderTests {
|
|
75
|
+
private func engine(_ session: ProviderTestSession, recorder: ProviderTestRecorder, suite: String = "recordings-provider-tests-\(UUID().uuidString)") throws -> RecordingEngine {
|
|
76
|
+
let configuration = try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("provider"), preferencesSuiteName: suite)
|
|
77
|
+
let engine = RecordingEngine(configuration: configuration, transcriptionProvider: ProviderTestAdapter(session: session))
|
|
78
|
+
engine.autoPasteEnabled = false
|
|
79
|
+
engine.microphoneAuthorization = { .authorized }
|
|
80
|
+
engine.accessibilityTrustCheck = { false }
|
|
81
|
+
engine.frontmostAppSnapshot = { nil }
|
|
82
|
+
engine.focusedWindowTitleLookup = { _ in nil }
|
|
83
|
+
engine.openAIAPIKeyProvider = { Issue.record("Isolated provider consulted legacy credentials"); return "" }
|
|
84
|
+
engine.commandCLI = { _, _, _ in Issue.record("Isolated provider launched legacy CLI"); return "ERROR: forbidden" }
|
|
85
|
+
engine.recorderFactory = { callback in recorder.attach(callback); return recorder }
|
|
86
|
+
return engine
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func eventually(_ condition: @MainActor () -> Bool) async -> Bool {
|
|
90
|
+
for _ in 0..<200 {
|
|
91
|
+
if condition() { return true }
|
|
92
|
+
try? await Task.sleep(for: .milliseconds(10))
|
|
93
|
+
}
|
|
94
|
+
return condition()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test("PCM streams before stop, pause excludes samples, and the final WAV matches the stream")
|
|
98
|
+
func liveStreamingAndPause() async throws {
|
|
99
|
+
let session = ProviderTestSession()
|
|
100
|
+
let recorder = ProviderTestRecorder()
|
|
101
|
+
let engine = try engine(session, recorder: recorder)
|
|
102
|
+
engine.startRecording()
|
|
103
|
+
let first = Data(repeating: 1, count: 4_800)
|
|
104
|
+
recorder.emit(first)
|
|
105
|
+
#expect(await eventually { engine.isRecording && session.snapshot.0 == first })
|
|
106
|
+
#expect(session.snapshot.1 == nil, "Streaming must precede finish")
|
|
107
|
+
session.emitPartial("spoken")
|
|
108
|
+
#expect(await eventually { engine.liveTranscriptionText == "spoken" })
|
|
109
|
+
engine.togglePause()
|
|
110
|
+
recorder.emit(Data(repeating: 9, count: 4_800))
|
|
111
|
+
engine.togglePause()
|
|
112
|
+
let tail = Data(repeating: 2, count: 1_600)
|
|
113
|
+
recorder.emit(tail)
|
|
114
|
+
engine.stopAndTranscribe()
|
|
115
|
+
#expect(await eventually { !engine.isTranscribing })
|
|
116
|
+
let request = try #require(session.snapshot.1)
|
|
117
|
+
#expect(session.snapshot.0 == first + tail)
|
|
118
|
+
#expect(session.endedPCM == [first + tail], "Input ends exactly once after the short tail, before file-based finish")
|
|
119
|
+
#expect(request.duration == Double(6_400) / 48_000)
|
|
120
|
+
let wav = try Data(contentsOf: request.audioURL)
|
|
121
|
+
#expect(String(data: wav.prefix(4), encoding: .utf8) == "RIFF")
|
|
122
|
+
#expect(Data(wav.dropFirst(44)) == first + tail)
|
|
123
|
+
let transcript = try #require(engine.recentTranscriptions.first)
|
|
124
|
+
#expect(transcript.captureID == request.captureID)
|
|
125
|
+
#expect(transcript.audioURL == request.audioURL)
|
|
126
|
+
#expect(transcript.rawText == "spoken words")
|
|
127
|
+
#expect(transcript.displayText == "Spoken words.")
|
|
128
|
+
#expect(engine.flowPhase == .ready("Transcript ready — auto-paste is off"))
|
|
129
|
+
#expect(engine.persistedRecordingRevision == 0, "An adapter result is not a persistence receipt")
|
|
130
|
+
#expect(recorder.stopped)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@Test("A paused provider receives the entire first packet without waiting for a network chunk or Stop",
|
|
134
|
+
arguments: [2, 1_600, 3_888, 4_800, 4_802])
|
|
135
|
+
func subChunkPCMReachesProviderBeforeStop(byteCount: Int) async throws {
|
|
136
|
+
let session = ProviderTestSession()
|
|
137
|
+
let recorder = ProviderTestRecorder()
|
|
138
|
+
let engine = try engine(session, recorder: recorder)
|
|
139
|
+
defer { engine.cancelRecording() }
|
|
140
|
+
let pcm = Data((0..<byteCount).map { UInt8(truncatingIfNeeded: $0 * 17) })
|
|
141
|
+
engine.startRecording()
|
|
142
|
+
recorder.emit(pcm)
|
|
143
|
+
#expect(await eventually { engine.isRecording })
|
|
144
|
+
engine.togglePause()
|
|
145
|
+
recorder.emit(Data(repeating: 9, count: 4_800))
|
|
146
|
+
#expect(await eventually { session.snapshot.0 == pcm }, "Pause must not withhold a partial network chunk")
|
|
147
|
+
#expect(session.receivedPackets == [pcm])
|
|
148
|
+
#expect(session.endedPCM.isEmpty)
|
|
149
|
+
#expect(session.snapshot.1 == nil)
|
|
150
|
+
|
|
151
|
+
engine.stopAndTranscribe()
|
|
152
|
+
#expect(await eventually { !engine.isTranscribing })
|
|
153
|
+
let request = try #require(session.snapshot.1)
|
|
154
|
+
#expect(session.receivedPackets == [pcm], "Stop must not repeat already delivered PCM")
|
|
155
|
+
#expect(session.endedPCM == [pcm])
|
|
156
|
+
#expect(Data(try Data(contentsOf: request.audioURL).dropFirst(44)) == pcm)
|
|
157
|
+
#expect(request.duration == Double(pcm.count) / 48_000)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@Test("Pause and resume preserve packet order; an admitted converter tail precedes inputEnded",
|
|
161
|
+
arguments: [false, true])
|
|
162
|
+
func orderedPauseResumeAndConverterTail(stopWhilePaused: Bool) async throws {
|
|
163
|
+
let session = ProviderTestSession()
|
|
164
|
+
let converterTail = Data([0x10, 0x20, 0x30, 0x40])
|
|
165
|
+
let recorder = ProviderTestRecorder(stopPCM: converterTail)
|
|
166
|
+
let engine = try engine(session, recorder: recorder)
|
|
167
|
+
defer { engine.cancelRecording() }
|
|
168
|
+
let first = Data(repeating: 1, count: 4_800)
|
|
169
|
+
let pauseTail = Data(repeating: 2, count: 3_888)
|
|
170
|
+
let resumed = [Data([3, 4]), Data(repeating: 5, count: 4_798), Data(repeating: 6, count: 9_602)]
|
|
171
|
+
engine.startRecording()
|
|
172
|
+
recorder.emit(first)
|
|
173
|
+
#expect(await eventually { engine.isRecording && session.snapshot.0 == first })
|
|
174
|
+
recorder.emit(pauseTail)
|
|
175
|
+
engine.togglePause()
|
|
176
|
+
recorder.emit(Data(repeating: 9, count: 5_000))
|
|
177
|
+
#expect(await eventually { session.snapshot.0 == first + pauseTail })
|
|
178
|
+
#expect(session.receivedPackets == [first, pauseTail])
|
|
179
|
+
engine.togglePause()
|
|
180
|
+
recorder.emit(Data())
|
|
181
|
+
for packet in resumed { recorder.emit(packet) }
|
|
182
|
+
let beforeStopPackets = [first, pauseTail] + resumed
|
|
183
|
+
let beforeStopPCM = beforeStopPackets.reduce(into: Data()) { $0.append($1) }
|
|
184
|
+
#expect(await eventually { session.snapshot.0 == beforeStopPCM })
|
|
185
|
+
#expect(session.receivedPackets == beforeStopPackets)
|
|
186
|
+
#expect(session.endedPCM.isEmpty)
|
|
187
|
+
if stopWhilePaused { engine.togglePause() }
|
|
188
|
+
|
|
189
|
+
engine.stopAndTranscribe()
|
|
190
|
+
#expect(await eventually { !engine.isTranscribing })
|
|
191
|
+
let request = try #require(session.snapshot.1)
|
|
192
|
+
let expectedPackets = beforeStopPackets + (stopWhilePaused ? [] : [converterTail])
|
|
193
|
+
let expectedPCM = expectedPackets.reduce(into: Data()) { $0.append($1) }
|
|
194
|
+
#expect(session.receivedPackets == expectedPackets)
|
|
195
|
+
#expect(session.endedPCM == [expectedPCM], "All admitted PCM must arrive exactly once before inputEnded")
|
|
196
|
+
#expect(Data(try Data(contentsOf: request.audioURL).dropFirst(44)) == expectedPCM)
|
|
197
|
+
#expect(request.duration == Double(expectedPCM.count) / 48_000)
|
|
198
|
+
#expect(recorder.stopped)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test("explicit no-target capture saves transcript and records copy-only non-delivery without AX or a paste")
|
|
202
|
+
func frozenNoTargetReceipt() async throws {
|
|
203
|
+
for copied in [true, false] {
|
|
204
|
+
let session = ProviderTestSession()
|
|
205
|
+
let recorder = ProviderTestRecorder()
|
|
206
|
+
let engine = try engine(session, recorder: recorder)
|
|
207
|
+
engine.autoPasteEnabled = true
|
|
208
|
+
engine.protectedOperationTrust = { Issue.record("No-target delivery must not request Accessibility"); return AccessibilityTrustResult(trusted: false, didPrompt: false) }
|
|
209
|
+
var writes: [String] = []
|
|
210
|
+
engine.pasteFallbackWriter = { text in writes.append(text); return copied }
|
|
211
|
+
engine.startRecording(pasteTarget: .frozen(nil))
|
|
212
|
+
recorder.emit(Data(repeating: 1, count: 4_800))
|
|
213
|
+
#expect(await eventually { engine.isRecording })
|
|
214
|
+
engine.stopAndTranscribe()
|
|
215
|
+
#expect(await eventually { engine.recentPastes.count == 1 })
|
|
216
|
+
let transcript = try #require(engine.recentTranscriptions.first)
|
|
217
|
+
let receipt = try #require(engine.recentPastes.first)
|
|
218
|
+
#expect(receipt.captureID == transcript.captureID)
|
|
219
|
+
#expect(receipt.deliveryStatus == .notDelivered)
|
|
220
|
+
#expect(!receipt.verified)
|
|
221
|
+
#expect(receipt.bundleIdentifier == nil)
|
|
222
|
+
#expect(receipt.appName == "No target app")
|
|
223
|
+
#expect(receipt.location == (copied ? "Clipboard only" : ""))
|
|
224
|
+
#expect(writes == ["Spoken words."])
|
|
225
|
+
#expect(engine.canStartRecording)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
@Test("cancel during provider finalization drops late text and allows another capture")
|
|
230
|
+
func cancellationDropsStaleCompletions() async throws {
|
|
231
|
+
let session = ProviderTestSession(delayFinish: true)
|
|
232
|
+
let recorder = ProviderTestRecorder()
|
|
233
|
+
let engine = try engine(session, recorder: recorder)
|
|
234
|
+
engine.startRecording()
|
|
235
|
+
recorder.emit(Data(repeating: 1, count: 4_800))
|
|
236
|
+
#expect(await eventually { engine.isRecording })
|
|
237
|
+
engine.stopAndTranscribe()
|
|
238
|
+
#expect(await eventually { session.snapshot.1 != nil })
|
|
239
|
+
engine.cancelRecording()
|
|
240
|
+
#expect(session.snapshot.2)
|
|
241
|
+
#expect(engine.canStartRecording)
|
|
242
|
+
session.emitPartial("late partial")
|
|
243
|
+
session.resolve()
|
|
244
|
+
try await Task.sleep(for: .milliseconds(50))
|
|
245
|
+
#expect(engine.liveTranscriptionText.isEmpty)
|
|
246
|
+
#expect(engine.recentTranscriptions.isEmpty)
|
|
247
|
+
#expect(engine.flowPhase == .idle)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@Test("discard during warm-up cancels the provider without a final request")
|
|
251
|
+
func warmupCancellation() throws {
|
|
252
|
+
let session = ProviderTestSession()
|
|
253
|
+
let recorder = ProviderTestRecorder()
|
|
254
|
+
let engine = try engine(session, recorder: recorder)
|
|
255
|
+
engine.startRecording()
|
|
256
|
+
engine.cancelRecording()
|
|
257
|
+
#expect(session.snapshot.2)
|
|
258
|
+
#expect(session.snapshot.1 == nil)
|
|
259
|
+
#expect(engine.canStartRecording)
|
|
260
|
+
#expect(recorder.stopped)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@Test("preferences are persistent per candidate suite and never write the legacy defaults")
|
|
264
|
+
func preferencesIsolation() throws {
|
|
265
|
+
let keys = ["useFnKey", "intentDetectionEnabled", "recordingsAutoPaste", "recordingsLanguage"]
|
|
266
|
+
let before = keys.map { UserDefaults.standard.object(forKey: $0) as? NSObject }
|
|
267
|
+
let firstSuite = "recordings-provider-tests-\(UUID().uuidString)"
|
|
268
|
+
let secondSuite = "recordings-provider-tests-\(UUID().uuidString)"
|
|
269
|
+
defer {
|
|
270
|
+
UserDefaults(suiteName: firstSuite)?.removePersistentDomain(forName: firstSuite)
|
|
271
|
+
UserDefaults(suiteName: secondSuite)?.removePersistentDomain(forName: secondSuite)
|
|
272
|
+
}
|
|
273
|
+
let first = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: firstSuite)
|
|
274
|
+
first.useFnKey = true // Must not install any monitor even when the preference changes.
|
|
275
|
+
first.intentDetectionEnabled = true
|
|
276
|
+
first.transcriptionLanguage = "ro"
|
|
277
|
+
first.autoPasteEnabled = true
|
|
278
|
+
let second = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: secondSuite)
|
|
279
|
+
#expect(!second.useFnKey)
|
|
280
|
+
#expect(!second.intentDetectionEnabled)
|
|
281
|
+
#expect(second.transcriptionLanguage == "en")
|
|
282
|
+
let reloaded = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: firstSuite)
|
|
283
|
+
#expect(reloaded.useFnKey)
|
|
284
|
+
#expect(reloaded.intentDetectionEnabled)
|
|
285
|
+
#expect(reloaded.transcriptionLanguage == "ro")
|
|
286
|
+
#expect(keys.map { UserDefaults.standard.object(forKey: $0) as? NSObject } == before)
|
|
287
|
+
#expect(!FileManager.default.fileExists(atPath: "\(first.home)/.hasna/recordings/config.json"))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@Test("the isolated initializer rejects the installed app's state and defaults")
|
|
291
|
+
func rejectsLiveRoots() throws {
|
|
292
|
+
#expect(throws: RecordingProviderError.self) {
|
|
293
|
+
try RecordingEngineConfiguration(isolatedHomePath: FileManager.default.homeDirectoryForCurrentUser.path, preferencesSuiteName: "candidate")
|
|
294
|
+
}
|
|
295
|
+
#expect(throws: RecordingProviderError.self) {
|
|
296
|
+
try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("provider"), preferencesSuiteName: "com.hasna.recordings")
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@Test("public paste receipts distinguish an unverified post from evidence of failure")
|
|
301
|
+
func typedPasteOutcomes() {
|
|
302
|
+
#expect(RecordingEngine.recentPasteDeliveryStatus(for: .pasted) == .confirmed)
|
|
303
|
+
#expect(RecordingEngine.recentPasteDeliveryStatus(for: .deliveredUnverified(.readBackNotAttempted)) == .unconfirmed)
|
|
304
|
+
#expect(RecordingEngine.recentPasteDeliveryStatus(for: .deliveryNotObserved) == .notDelivered)
|
|
305
|
+
#expect(RecordingEngine.recentPasteDeliveryStatus(for: .clipboardWriteFailed) == .notDelivered)
|
|
306
|
+
#expect(RecordingEngine.recentPasteDeliveryStatus(for: .targetUnavailable) == .notDelivered)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -6,7 +6,7 @@ struct RecordingStartGateTests {
|
|
|
6
6
|
@Test("permission-only launch skips global handlers and terminates after handling")
|
|
7
7
|
func permissionHelperLaunchPlan() {
|
|
8
8
|
let plan = PermissionRequestLaunchPlan(arguments: [
|
|
9
|
-
"/Applications/
|
|
9
|
+
"/Applications/Hasna Recordings.app/Contents/MacOS/Recordings",
|
|
10
10
|
"--request-permissions",
|
|
11
11
|
"--open-permission-settings",
|
|
12
12
|
])
|
|
@@ -11,8 +11,14 @@ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
|
|
|
11
11
|
private let lock = NSLock()
|
|
12
12
|
private var startedFlag = false
|
|
13
13
|
private var stoppedFlag = false
|
|
14
|
+
private var stoppingFlag = false
|
|
15
|
+
private let stopGate: DispatchSemaphore?
|
|
14
16
|
private var onPCM: (@Sendable (Data) -> Void)?
|
|
15
17
|
|
|
18
|
+
init(stopGate: DispatchSemaphore? = nil) { self.stopGate = stopGate }
|
|
19
|
+
|
|
20
|
+
var isStopping: Bool { lock.withLock { stoppingFlag } }
|
|
21
|
+
|
|
16
22
|
var started: Bool {
|
|
17
23
|
lock.withLock { startedFlag }
|
|
18
24
|
}
|
|
@@ -46,6 +52,8 @@ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
|
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
func stop() {
|
|
55
|
+
lock.withLock { stoppingFlag = true }
|
|
56
|
+
_ = stopGate?.wait(timeout: .now() + 2)
|
|
49
57
|
lock.withLock { stoppedFlag = true }
|
|
50
58
|
}
|
|
51
59
|
}
|
|
@@ -53,9 +61,24 @@ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
|
|
|
53
61
|
@MainActor
|
|
54
62
|
private func makeStartableEngine(
|
|
55
63
|
recorder: FakePCMRecorder,
|
|
64
|
+
intentDetectionEnabled: Bool = false,
|
|
56
65
|
selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken? = { _ in nil }
|
|
57
66
|
) -> RecordingEngine {
|
|
58
|
-
let engine = RecordingEngine(homePath: makeIsolatedTestHome("start-timing-tests"))
|
|
67
|
+
let engine = RecordingEngine(homePath: makeIsolatedTestHome("start-timing-tests"), installsGlobalHandlers: false)
|
|
68
|
+
// The legacy engine persists this setting. Keep the explicit mode on this instance,
|
|
69
|
+
// restoring the exact application-domain value before another MainActor test can run.
|
|
70
|
+
// This factory and the restoration are synchronous: there is no suspension in between.
|
|
71
|
+
let defaults = UserDefaults.standard
|
|
72
|
+
let domain = Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName
|
|
73
|
+
let priorIntent = defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"]
|
|
74
|
+
defer {
|
|
75
|
+
if let priorIntent {
|
|
76
|
+
defaults.set(priorIntent, forKey: "intentDetectionEnabled")
|
|
77
|
+
} else {
|
|
78
|
+
defaults.removeObject(forKey: "intentDetectionEnabled")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
engine.intentDetectionEnabled = intentDetectionEnabled
|
|
59
82
|
engine.openAIAPIKeyProvider = { "" }
|
|
60
83
|
engine.microphoneAuthorization = { .authorized }
|
|
61
84
|
engine.accessibilityTrustCheck = { true }
|
|
@@ -74,6 +97,27 @@ private func makeStartableEngine(
|
|
|
74
97
|
return engine
|
|
75
98
|
}
|
|
76
99
|
|
|
100
|
+
/// An entered callback is essential evidence: with intent detection disabled the engine
|
|
101
|
+
/// correctly skips selection capture, so merely installing a blocked closure proves nothing.
|
|
102
|
+
private final class BlockedSelectionCapture: @unchecked Sendable {
|
|
103
|
+
private let lock = NSLock()
|
|
104
|
+
private let gate = DispatchSemaphore(value: 0)
|
|
105
|
+
private var capturedPIDs: [pid_t] = []
|
|
106
|
+
private var returned = false
|
|
107
|
+
|
|
108
|
+
var pids: [pid_t] { lock.withLock { capturedPIDs } }
|
|
109
|
+
var isPending: Bool { lock.withLock { !capturedPIDs.isEmpty && !returned } }
|
|
110
|
+
|
|
111
|
+
func capture(_ pid: pid_t) -> AccessibilitySelectionToken? {
|
|
112
|
+
lock.withLock { capturedPIDs.append(pid) }
|
|
113
|
+
gate.wait()
|
|
114
|
+
lock.withLock { returned = true }
|
|
115
|
+
return AccessibilitySelectionToken.unsafeTestToken(selectedText: "frozen words")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func release() { gate.signal() }
|
|
119
|
+
}
|
|
120
|
+
|
|
77
121
|
@MainActor
|
|
78
122
|
private func waitUntil(
|
|
79
123
|
timeout: TimeInterval = 5,
|
|
@@ -130,14 +174,12 @@ private func expectEmptyAttemptDisclosed(
|
|
|
130
174
|
@MainActor
|
|
131
175
|
struct RecordingStartTimingTests {
|
|
132
176
|
@Test("the recorder starts on keydown even while the AX selection capture is blocked")
|
|
133
|
-
func recorderStartDoesNotWaitOnSelectionCapture() async {
|
|
134
|
-
let
|
|
177
|
+
func recorderStartDoesNotWaitOnSelectionCapture() async throws {
|
|
178
|
+
let capture = BlockedSelectionCapture()
|
|
179
|
+
defer { capture.release() }
|
|
135
180
|
let recorder = FakePCMRecorder()
|
|
136
|
-
let engine = makeStartableEngine(recorder: recorder) {
|
|
137
|
-
|
|
138
|
-
// any acceptable start budget.
|
|
139
|
-
captureGate.wait()
|
|
140
|
-
return nil
|
|
181
|
+
let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: true) { pid in
|
|
182
|
+
capture.capture(pid)
|
|
141
183
|
}
|
|
142
184
|
|
|
143
185
|
// `startRecording` runs synchronously on the MainActor through recorder start.
|
|
@@ -149,25 +191,29 @@ struct RecordingStartTimingTests {
|
|
|
149
191
|
#expect(engine.isWarmingUpCapture, "start() returning is warm-up, not captured audio")
|
|
150
192
|
#expect(engine.captureIsActive)
|
|
151
193
|
#expect(engine.flowPhase == .listening)
|
|
194
|
+
try #require(await waitUntil { capture.isPending })
|
|
195
|
+
#expect(capture.pids == [99_999])
|
|
152
196
|
|
|
153
197
|
#expect(await confirmCapture(engine, recorder))
|
|
154
198
|
#expect(!engine.isWarmingUpCapture)
|
|
155
199
|
|
|
156
|
-
|
|
200
|
+
capture.release()
|
|
157
201
|
engine.cancelRecording()
|
|
158
202
|
#expect(engine.flowPhase == .idle)
|
|
159
203
|
}
|
|
160
204
|
|
|
161
205
|
@Test("stopping waits for the generation-bound start context instead of dropping the frozen target")
|
|
162
|
-
func stopAwaitsFrozenStartContext() async {
|
|
163
|
-
let
|
|
206
|
+
func stopAwaitsFrozenStartContext() async throws {
|
|
207
|
+
let capture = BlockedSelectionCapture()
|
|
208
|
+
defer { capture.release() }
|
|
164
209
|
let recorder = FakePCMRecorder()
|
|
165
|
-
let engine = makeStartableEngine(recorder: recorder) {
|
|
166
|
-
|
|
167
|
-
return AccessibilitySelectionToken.unsafeTestToken(selectedText: "frozen words")
|
|
210
|
+
let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: true) { pid in
|
|
211
|
+
capture.capture(pid)
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
engine.startRecording(trigger: .manual)
|
|
215
|
+
try #require(await waitUntil { capture.isPending })
|
|
216
|
+
#expect(capture.pids == [99_999])
|
|
171
217
|
#expect(await confirmCapture(engine, recorder))
|
|
172
218
|
engine.stopAndTranscribe()
|
|
173
219
|
#expect(engine.isTranscribing)
|
|
@@ -175,9 +221,10 @@ struct RecordingStartTimingTests {
|
|
|
175
221
|
// The pipeline must hold in finalizing while the frozen context is unresolved —
|
|
176
222
|
// it may not deliver without the selection frozen at start.
|
|
177
223
|
try? await Task.sleep(for: .milliseconds(150))
|
|
224
|
+
#expect(capture.isPending)
|
|
178
225
|
#expect(engine.flowPhase == .finalizing)
|
|
179
226
|
|
|
180
|
-
|
|
227
|
+
capture.release()
|
|
181
228
|
// No audio was produced by the fake recorder, so the pipeline ends in the
|
|
182
229
|
// fail-closed no-audio state — importantly, only after the context resolved.
|
|
183
230
|
#expect(await waitUntil {
|
|
@@ -188,20 +235,75 @@ struct RecordingStartTimingTests {
|
|
|
188
235
|
#expect(engine.canStartRecording)
|
|
189
236
|
}
|
|
190
237
|
|
|
238
|
+
@Test("intent detection disabled skips selection capture and can finish the empty attempt")
|
|
239
|
+
func disabledIntentDoesNotWaitForSelection() async throws {
|
|
240
|
+
let capture = BlockedSelectionCapture()
|
|
241
|
+
defer { capture.release() }
|
|
242
|
+
let recorder = FakePCMRecorder()
|
|
243
|
+
let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: false) { pid in
|
|
244
|
+
capture.capture(pid)
|
|
245
|
+
}
|
|
246
|
+
engine.startRecording(trigger: .manual)
|
|
247
|
+
try #require(await confirmCapture(engine, recorder))
|
|
248
|
+
engine.stopAndTranscribe()
|
|
249
|
+
try #require(await waitUntil { !engine.isTranscribing })
|
|
250
|
+
#expect(capture.pids.isEmpty)
|
|
251
|
+
#expect(engine.flowPhase == .failed("No audio captured"))
|
|
252
|
+
#expect(engine.canStartRecording)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@Test("fixture intent modes stay on their engine without changing the process preference")
|
|
256
|
+
func fixtureIntentModeIsInstanceScoped() {
|
|
257
|
+
let defaults = UserDefaults.standard
|
|
258
|
+
let domain = Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName
|
|
259
|
+
let before = defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"] as? NSObject
|
|
260
|
+
let enabled = makeStartableEngine(recorder: FakePCMRecorder(), intentDetectionEnabled: true)
|
|
261
|
+
#expect(enabled.intentDetectionEnabled)
|
|
262
|
+
#expect((defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"] as? NSObject) == before)
|
|
263
|
+
let disabled = makeStartableEngine(recorder: FakePCMRecorder())
|
|
264
|
+
#expect(!disabled.intentDetectionEnabled)
|
|
265
|
+
#expect(enabled.intentDetectionEnabled)
|
|
266
|
+
#expect((defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"] as? NSObject) == before)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@Test("slow microphone shutdown keeps the UI responsive and finishes draining before transcription")
|
|
270
|
+
func stopDoesNotBlockMainActor() async {
|
|
271
|
+
let stopGate = DispatchSemaphore(value: 0)
|
|
272
|
+
let recorder = FakePCMRecorder(stopGate: stopGate)
|
|
273
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
274
|
+
engine.startRecording(trigger: .manual)
|
|
275
|
+
#expect(await confirmCapture(engine, recorder))
|
|
276
|
+
|
|
277
|
+
let started = ContinuousClock.now
|
|
278
|
+
engine.stopAndTranscribe()
|
|
279
|
+
#expect(ContinuousClock.now - started < .milliseconds(300))
|
|
280
|
+
#expect(await waitUntil { recorder.isStopping })
|
|
281
|
+
#expect(engine.isTranscribing)
|
|
282
|
+
#expect(!engine.canStartRecording)
|
|
283
|
+
#expect(!recorder.isStopped)
|
|
284
|
+
|
|
285
|
+
stopGate.signal()
|
|
286
|
+
#expect(await waitUntil { !engine.isTranscribing })
|
|
287
|
+
#expect(recorder.isStopped)
|
|
288
|
+
#expect(engine.statusMessage == "No audio captured")
|
|
289
|
+
}
|
|
290
|
+
|
|
191
291
|
@Test("a released key before the capture resolves still cancels cleanly")
|
|
192
|
-
func cancelDuringPendingCapture() async {
|
|
193
|
-
let
|
|
292
|
+
func cancelDuringPendingCapture() async throws {
|
|
293
|
+
let capture = BlockedSelectionCapture()
|
|
294
|
+
defer { capture.release() }
|
|
194
295
|
let recorder = FakePCMRecorder()
|
|
195
|
-
let engine = makeStartableEngine(recorder: recorder) {
|
|
196
|
-
|
|
197
|
-
return nil
|
|
296
|
+
let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: true) { pid in
|
|
297
|
+
capture.capture(pid)
|
|
198
298
|
}
|
|
199
299
|
engine.startRecording(trigger: .manual)
|
|
300
|
+
try #require(await waitUntil { capture.isPending })
|
|
301
|
+
#expect(capture.pids == [99_999])
|
|
200
302
|
#expect(await confirmCapture(engine, recorder))
|
|
201
303
|
engine.cancelRecording()
|
|
202
304
|
#expect(!engine.isRecording)
|
|
203
305
|
#expect(engine.flowPhase == .idle)
|
|
204
|
-
|
|
306
|
+
capture.release()
|
|
205
307
|
#expect(engine.canStartRecording)
|
|
206
308
|
}
|
|
207
309
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
struct ServiceAPIConfigurationTests {
|
|
6
|
+
@Test("a configurable service prefix keeps exactly one v1 segment", arguments: [
|
|
7
|
+
"https://api.example.com/recordings", "https://api.example.com/recordings/v1/",
|
|
8
|
+
])
|
|
9
|
+
func normalizesServicePrefix(_ input: String) throws {
|
|
10
|
+
#expect(try ServiceAPIConfiguration.normalizedURL(input) == "https://api.example.com/recordings/v1")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Test("invalid or credential-bearing URLs are rejected", arguments: [
|
|
14
|
+
"", "https://user:password@example.com/v1", "http://example.com/v1",
|
|
15
|
+
"https://example.com/v1?key=value", "https://example.com/v1#fragment",
|
|
16
|
+
])
|
|
17
|
+
func rejectsUnsafeURL(_ input: String) {
|
|
18
|
+
#expect(throws: (any Error).self) { try ServiceAPIConfiguration.normalizedURL(input) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test("Finder launches receive saved routing and an endpoint-bound Keychain credential")
|
|
22
|
+
func savedConnectionEnvironment() throws {
|
|
23
|
+
let suite = "recordings-api-test-\(UUID().uuidString)"
|
|
24
|
+
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
25
|
+
defer { defaults.removePersistentDomain(forName: suite) }
|
|
26
|
+
defaults.set("https://service.example.test/recordings/v1/", forKey: ServiceAPIConfiguration.defaultsKey)
|
|
27
|
+
var lookedUp: [String] = []
|
|
28
|
+
let environment = try ServiceAPIConfiguration.childEnvironment(base: ["PATH": "/bin"], defaults: defaults) { endpoint in
|
|
29
|
+
lookedUp.append(endpoint)
|
|
30
|
+
return "fixture-service-credential"
|
|
31
|
+
}
|
|
32
|
+
#expect(lookedUp == ["https://service.example.test/recordings/v1"])
|
|
33
|
+
#expect(environment["HASNA_RECORDINGS_API_URL"] == lookedUp.first)
|
|
34
|
+
#expect(environment["HASNA_RECORDINGS_CLIENT_STORE"] == nil)
|
|
35
|
+
#expect(environment["HASNA_RECORDINGS_API_KEY_OVERRIDE"] != nil)
|
|
36
|
+
#expect(environment["OPENAI_API_KEY"] == nil)
|
|
37
|
+
#expect(environment["PATH"] == "/bin")
|
|
38
|
+
#expect(defaults.string(forKey: "HASNA_RECORDINGS_API_KEY_OVERRIDE") == nil)
|
|
39
|
+
for base in [["HASNA_RECORDINGS_API_URL": "https://another.example.test/v1"], ["HASNA_RECORDINGS_LOCAL": "1"], ["RECORDINGS_LOCAL": "1"]] {
|
|
40
|
+
let overridden = try ServiceAPIConfiguration.childEnvironment(base: base, defaults: defaults) { _ in
|
|
41
|
+
Issue.record("Environment routing must not read the saved endpoint credential")
|
|
42
|
+
return nil
|
|
43
|
+
}
|
|
44
|
+
#expect(overridden == base)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test("an unconfigured native client invents no endpoint or local fallback")
|
|
49
|
+
func noDefaultEndpoint() throws {
|
|
50
|
+
let suite = "recordings-empty-api-test-\(UUID().uuidString)"
|
|
51
|
+
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
52
|
+
defer { defaults.removePersistentDomain(forName: suite) }
|
|
53
|
+
#expect(try ServiceAPIConfiguration.childEnvironment(base: [:], defaults: defaults) { _ in
|
|
54
|
+
Issue.record("No endpoint is configured")
|
|
55
|
+
return nil
|
|
56
|
+
}.isEmpty)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@MainActor
|
|
60
|
+
@Test("global recording preferences ignore legacy project selections and prompts")
|
|
61
|
+
func ignoresLegacyProjects() {
|
|
62
|
+
let home = makeIsolatedTestHome("global-recording-preferences")
|
|
63
|
+
let preferences = ProjectStore(filePath: "\(home)/projects.json")
|
|
64
|
+
preferences.settings.globalSystemPrompt = "Global cleanup"
|
|
65
|
+
preferences.settings.postProcessingMode = "always"
|
|
66
|
+
preferences.settings.projects = [RecProject(id: "legacy", name: "Legacy", systemPrompt: "Project cleanup")]
|
|
67
|
+
preferences.settings.activeProjectId = "legacy"
|
|
68
|
+
let engine = RecordingEngine(homePath: home, installsGlobalHandlers: false)
|
|
69
|
+
engine.globalRecordingPreferences = preferences
|
|
70
|
+
#expect(engine.projectStore == nil)
|
|
71
|
+
#expect(engine.recordingCleanupPreferences.prompt == "Global cleanup")
|
|
72
|
+
#expect(engine.recordingCleanupPreferences.mode == "always")
|
|
73
|
+
#expect(preferences.settings.activeProjectId == "legacy")
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
struct SettingsWindowControllerTests {
|
|
7
|
+
@Test("Settings creates one window and reopens it after closing")
|
|
8
|
+
func reusesSettingsWindow() {
|
|
9
|
+
let policy = NSApplication.shared.activationPolicy()
|
|
10
|
+
var creations = 0
|
|
11
|
+
let controller = SettingsWindowController {
|
|
12
|
+
creations += 1
|
|
13
|
+
return NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 500))
|
|
14
|
+
}
|
|
15
|
+
#expect(creations == 0)
|
|
16
|
+
let first = controller.show()
|
|
17
|
+
defer { first.close() }
|
|
18
|
+
#expect(first.isVisible)
|
|
19
|
+
#expect(first.title == "Hasna Recordings Settings")
|
|
20
|
+
first.close()
|
|
21
|
+
#expect(!first.isVisible)
|
|
22
|
+
let reopened = controller.show()
|
|
23
|
+
#expect(reopened === first)
|
|
24
|
+
#expect(reopened.isVisible)
|
|
25
|
+
#expect(creations == 1)
|
|
26
|
+
#expect(NSApplication.shared.activationPolicy() == policy)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -16,7 +16,11 @@ import Foundation
|
|
|
16
16
|
/// back only the engine and so have no scope to remove it from; `CLIRunnerTests` instead
|
|
17
17
|
/// passes the temp home it already creates and already deletes.
|
|
18
18
|
func makeIsolatedTestHome(_ label: String) -> String {
|
|
19
|
-
|
|
19
|
+
// Foundation can ignore TMPDIR under the Darwin test-bundle launcher. An
|
|
20
|
+
// explicit fixture root keeps confined tests inside their owned write scope.
|
|
21
|
+
let root = ProcessInfo.processInfo.environment["RECORDINGS_TEST_TMP_ROOT"]
|
|
22
|
+
.map { URL(fileURLWithPath: $0, isDirectory: true) } ?? FileManager.default.temporaryDirectory
|
|
23
|
+
let url = root
|
|
20
24
|
.appendingPathComponent("recordings-\(label)-\(UUID().uuidString)")
|
|
21
25
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
22
26
|
return url.path
|