@hasna/recordings 0.4.0 → 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.
Files changed (58) hide show
  1. package/README.md +56 -6
  2. package/contracts/v1/fixtures.json +1206 -0
  3. package/dist/cli/index.js +28 -7
  4. package/dist/contracts/hosted-v1.d.ts +105 -0
  5. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  6. package/dist/contracts/hosted-v1.js +41 -0
  7. package/dist/contracts/stream-v1.d.ts +117 -0
  8. package/dist/contracts/stream-v1.d.ts.map +1 -0
  9. package/dist/contracts/stream-v1.js +35 -0
  10. package/dist/hosted/index.d.ts +58 -0
  11. package/dist/hosted/index.d.ts.map +1 -0
  12. package/dist/hosted/index.js +266 -0
  13. package/dist/hosted/transport.d.ts +36 -0
  14. package/dist/hosted/transport.d.ts.map +1 -0
  15. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  16. package/dist/hosted-v1-gdr9extc.js +84 -0
  17. package/dist/index.js +27 -6
  18. package/dist/mcp/index.js +27 -6
  19. package/dist/server/index.js +27 -6
  20. package/dist/storage.js +27 -6
  21. package/docs/hosted-sdk.md +71 -0
  22. package/docs/wire-contracts.md +24 -0
  23. package/package.json +27 -6
  24. package/scripts/ci-linux-suite.ts +23 -13
  25. package/scripts/macos_artifact.ts +40 -33
  26. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  27. package/scripts/native/recordings_fs_guard.c +36 -4
  28. package/scripts/native-core-receipt.py +171 -0
  29. package/scripts/native_fs_guard.ts +2 -0
  30. package/scripts/release-suite-gate.ts +227 -150
  31. package/scripts/resolve_tailscale_cli.sh +24 -3
  32. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  33. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  34. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  35. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
  36. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  37. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
  38. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  39. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
  40. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  41. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  42. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  43. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  44. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  45. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +115 -2
  46. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
  47. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  48. package/src/native/Recordings/build.sh +2 -1
  49. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  50. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  51. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  52. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  53. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  54. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  55. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  56. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  57. package/dist/__tests__/preload.d.ts +0 -2
  58. package/dist/__tests__/preload.d.ts.map +0 -1
@@ -0,0 +1,215 @@
1
+ import Darwin
2
+ import Foundation
3
+ import Testing
4
+
5
+ /// Test-only observation of the original pipes, independent of transient pre-exec copies.
6
+ enum PipeClosureFixture {
7
+ static func now() -> UInt64 {
8
+ var value = timespec()
9
+ precondition(clock_gettime(CLOCK_MONOTONIC, &value) == 0)
10
+ return UInt64(value.tv_sec) * 1_000_000_000 + UInt64(value.tv_nsec)
11
+ }
12
+
13
+ static func request(pid: pid_t, deadlineFile: URL) throws -> UInt64 {
14
+ let deadline = now() + 3_000_000_000
15
+ try String(deadline).write(to: deadlineFile, atomically: true, encoding: .utf8)
16
+ try #require(Darwin.kill(pid, SIGUSR1) == 0)
17
+ return deadline
18
+ }
19
+
20
+ static func marker(at file: URL) -> String? {
21
+ guard let value = try? String(contentsOf: file, encoding: .utf8), !value.isEmpty else { return nil }
22
+ return value
23
+ }
24
+
25
+ static func waitForMarker(at file: URL, deadline: UInt64) -> String? {
26
+ while now() < deadline {
27
+ if let value = marker(at: file), now() < deadline { return value }
28
+ Thread.sleep(forTimeInterval: 0.01)
29
+ }
30
+ return nil
31
+ }
32
+
33
+ // Shared by both silent-holder fixtures and the deterministic observer controls.
34
+ // Success is published atomically only when both EPIPE observations are before the
35
+ // same parent-supplied deadline. Retry the observation, never the command or test.
36
+ static let observerSource = #"""
37
+ #include <errno.h>
38
+ #include <fcntl.h>
39
+ #include <limits.h>
40
+ #include <stdint.h>
41
+ #include <stdio.h>
42
+ #include <time.h>
43
+ #include <unistd.h>
44
+
45
+ static uint64_t monotonic_ns(void) {
46
+ struct timespec value;
47
+ if (clock_gettime(CLOCK_MONOTONIC, &value)) _exit(70);
48
+ return (uint64_t)value.tv_sec * 1000000000ULL + (uint64_t)value.tv_nsec;
49
+ }
50
+
51
+ static int observePipeClosure(const char *deadlinePath, const char *markerPath,
52
+ const char *detailsPath, const char *connectedPath, void (*owners)(FILE *, int)) {
53
+ unsigned long long deadline;
54
+ FILE *request = fopen(deadlinePath, "r");
55
+ if (!request) return 71;
56
+ int parsed = fscanf(request, "%llu", &deadline);
57
+ fclose(request);
58
+ if (parsed != 1) return 72;
59
+ for (int fd = STDOUT_FILENO; fd <= STDERR_FILENO; fd++) {
60
+ int flags = fcntl(fd, F_GETFL);
61
+ if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return 73;
62
+ }
63
+ ssize_t results[2] = {0, 0}, first[2] = {0, 0};
64
+ int errors[2] = {0, 0}, firstErrors[2] = {0, 0}, broken[2] = {0, 0};
65
+ unsigned attempts = 0;
66
+ uint64_t observedAt = 0;
67
+ int invalid = 0;
68
+ while (monotonic_ns() < deadline) {
69
+ for (int i = 0; i < 2; i++) {
70
+ if (broken[i]) continue;
71
+ errno = 0;
72
+ results[i] = write(STDOUT_FILENO + i, "x", 1);
73
+ errors[i] = errno;
74
+ broken[i] = results[i] == -1 && errors[i] == EPIPE;
75
+ if (results[i] != 1 && !broken[i] &&
76
+ !(results[i] == -1 && (errors[i] == EINTR || errors[i] == EAGAIN))) invalid = 1;
77
+ }
78
+ observedAt = monotonic_ns();
79
+ if (attempts++ == 0) {
80
+ for (int i = 0; i < 2; i++) { first[i] = results[i]; firstErrors[i] = errors[i]; }
81
+ if (connectedPath && !broken[0] && !broken[1] && !invalid) {
82
+ FILE *connected = fopen(connectedPath, "w");
83
+ if (!connected) return 74;
84
+ fputs("connected", connected);
85
+ fclose(connected);
86
+ }
87
+ }
88
+ if (invalid || (broken[0] && broken[1]) || observedAt >= deadline) break;
89
+ uint64_t remaining = deadline - observedAt;
90
+ struct timespec delay = {0, (long)(remaining < 10000000 ? remaining : 10000000)};
91
+ nanosleep(&delay, NULL);
92
+ }
93
+ int success = !invalid && broken[0] && broken[1] && observedAt < deadline;
94
+ FILE *details = detailsPath ? fopen(detailsPath, "w") : NULL;
95
+ if (details) {
96
+ fprintf(details, "first_stdout=%ld,errno=%d; first_stderr=%ld,errno=%d; stdout=%ld,errno=%d; stderr=%ld,errno=%d; attempts=%u; observed_ns=%llu; deadline_ns=%llu",
97
+ (long)first[0], firstErrors[0], (long)first[1], firstErrors[1],
98
+ (long)results[0], errors[0], (long)results[1], errors[1], attempts,
99
+ (unsigned long long)observedAt, deadline);
100
+ if (!success && owners) { owners(details, STDOUT_FILENO); owners(details, STDERR_FILENO); }
101
+ fclose(details);
102
+ }
103
+ char pending[PATH_MAX];
104
+ int length = snprintf(pending, sizeof(pending), "%s.pending", markerPath);
105
+ if (length < 0 || length >= (int)sizeof(pending)) return 75;
106
+ FILE *marker = fopen(pending, "w");
107
+ if (!marker) return 76;
108
+ fputs(success ? "both-epipe" : invalid ? "probe-error" : "still-connected", marker);
109
+ if (fclose(marker) || rename(pending, markerPath)) return 77;
110
+ return 0;
111
+ }
112
+ """#
113
+ }
114
+
115
+ extension CLIRunnerTests {
116
+ @Test("pipe closure observer distinguishes transient readers from one persistent reader", arguments: [false, true])
117
+ func pipeClosureObserverControls(persistentReader: Bool) throws {
118
+ let root = FileManager.default.temporaryDirectory
119
+ .appendingPathComponent("recordings-pipe-observer-\(UUID().uuidString)")
120
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
121
+ defer { try? FileManager.default.removeItem(at: root) }
122
+ let source = root.appendingPathComponent("observer.c")
123
+ let executable = root.appendingPathComponent("observer")
124
+ let ready = root.appendingPathComponent("ready")
125
+ let deadlineFile = root.appendingPathComponent("deadline")
126
+ let marker = root.appendingPathComponent("result")
127
+ let details = root.appendingPathComponent("details")
128
+ let connected = root.appendingPathComponent("connected")
129
+ try """
130
+ #include <signal.h>
131
+ \(PipeClosureFixture.observerSource)
132
+ static volatile sig_atomic_t requested = 0;
133
+ static void requestProbe(int signo) { (void)signo; requested = 1; }
134
+ int main(int argc, char **argv) {
135
+ if (argc != 6) return 64;
136
+ signal(SIGPIPE, SIG_IGN);
137
+ signal(SIGUSR1, requestProbe);
138
+ FILE *ready = fopen(argv[1], "w");
139
+ if (!ready) return 65;
140
+ fputs("ready", ready);
141
+ fclose(ready);
142
+ while (!requested) {
143
+ struct timespec delay = {0, 1000000};
144
+ nanosleep(&delay, NULL);
145
+ }
146
+ return observePipeClosure(argv[2], argv[3], argv[4], argv[5], NULL);
147
+ }
148
+ """.write(to: source, atomically: true, encoding: .utf8)
149
+ let compile = Process()
150
+ compile.executableURL = URL(fileURLWithPath: "/usr/bin/cc")
151
+ compile.arguments = ["-Wall", "-Wextra", "-Werror", "-o", executable.path, source.path]
152
+ try compile.run()
153
+ compile.waitUntilExit()
154
+ try #require(compile.terminationStatus == 0)
155
+
156
+ let output = Pipe(), error = Pipe()
157
+ var leases: [Int32] = []
158
+ defer {
159
+ for descriptor in leases where descriptor >= 0 { _ = Darwin.close(descriptor) }
160
+ for handle in [output.fileHandleForReading, output.fileHandleForWriting,
161
+ error.fileHandleForReading, error.fileHandleForWriting] { try? handle.close() }
162
+ }
163
+ for pipe in [output, error] {
164
+ let descriptor = Darwin.fcntl(pipe.fileHandleForReading.fileDescriptor, F_DUPFD_CLOEXEC, 3)
165
+ try #require(descriptor >= 0)
166
+ leases.append(descriptor)
167
+ }
168
+ let child = Process()
169
+ child.executableURL = executable
170
+ child.arguments = [ready.path, deadlineFile.path, marker.path, details.path, connected.path]
171
+ child.standardOutput = output
172
+ child.standardError = error
173
+ try child.run()
174
+ defer {
175
+ if child.isRunning { _ = Darwin.kill(child.processIdentifier, SIGKILL) }
176
+ child.waitUntilExit()
177
+ }
178
+ try output.fileHandleForReading.close()
179
+ try error.fileHandleForReading.close()
180
+ try output.fileHandleForWriting.close()
181
+ try error.fileHandleForWriting.close()
182
+ try #require(PipeClosureFixture.waitForMarker(at: ready, deadline: PipeClosureFixture.now() + 3_000_000_000) == "ready")
183
+ let deadline = try PipeClosureFixture.request(pid: child.processIdentifier, deadlineFile: deadlineFile)
184
+ // The observer must actually see retained readers before this test releases
185
+ // either lease. No delay or scheduling assumption substitutes for that handshake.
186
+ try #require(PipeClosureFixture.waitForMarker(at: connected, deadline: deadline) == "connected")
187
+ try #require(Darwin.close(leases[0]) == 0)
188
+ leases[0] = -1
189
+ if !persistentReader {
190
+ try #require(Darwin.close(leases[1]) == 0)
191
+ leases[1] = -1
192
+ #expect(PipeClosureFixture.waitForMarker(at: marker, deadline: deadline) == "both-epipe")
193
+ }
194
+ // This extra second is only for reaping/reporting the negative control after
195
+ // its original deadline expires. It cannot admit a late successful observation.
196
+ while child.isRunning && PipeClosureFixture.now() < deadline + 1_000_000_000 {
197
+ Thread.sleep(forTimeInterval: 0.01)
198
+ }
199
+ try #require(!child.isRunning)
200
+ child.waitUntilExit()
201
+ #expect(child.terminationStatus == 0)
202
+ let observations = try String(contentsOf: details, encoding: .utf8)
203
+ #expect(observations.contains("first_stdout=1,errno=0; first_stderr=1,errno=0"))
204
+ if persistentReader {
205
+ #expect(PipeClosureFixture.now() >= deadline)
206
+ #expect(PipeClosureFixture.marker(at: marker) == "still-connected")
207
+ #expect(observations.contains("stdout=-1,errno=\(EPIPE); stderr=1,errno=0"))
208
+ try #require(Darwin.close(leases[1]) == 0)
209
+ leases[1] = -1
210
+ #expect(PipeClosureFixture.marker(at: marker) == "still-connected")
211
+ } else {
212
+ #expect(observations.contains("stdout=-1,errno=\(EPIPE); stderr=-1,errno=\(EPIPE)"))
213
+ }
214
+ }
215
+ }
@@ -27,7 +27,7 @@ struct ProjectStoreTests {
27
27
  @Test("legacy app projects migrate to canonical Store ids without losing metadata")
28
28
  @MainActor
29
29
  func migratesProjectsToCanonicalStore() async throws {
30
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
30
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
31
31
  defer { try? FileManager.default.removeItem(at: root) }
32
32
  let bin = root.appendingPathComponent(".bun/bin")
33
33
  try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
@@ -80,7 +80,7 @@ struct ProjectStoreTests {
80
80
  @Test("duplicate legacy project ids reconcile without crashing or duplicating canonical rows")
81
81
  @MainActor
82
82
  func duplicateProjectIDsAreDeduplicated() async throws {
83
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
83
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
84
84
  defer { try? FileManager.default.removeItem(at: root) }
85
85
  let bin = root.appendingPathComponent(".bun/bin")
86
86
  try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
@@ -110,7 +110,7 @@ struct ProjectStoreTests {
110
110
  @Test("failed canonical registration preserves recording metadata and can be retried")
111
111
  @MainActor
112
112
  func failedRegistrationDoesNotDisableCaptureForAppLifetime() async throws {
113
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
113
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
114
114
  defer { try? FileManager.default.removeItem(at: root) }
115
115
  let bin = root.appendingPathComponent(".bun/bin")
116
116
  try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
@@ -180,7 +180,7 @@ struct ProjectStoreTests {
180
180
  @Test("adding a project preserves color in canonical local metadata")
181
181
  @MainActor
182
182
  func addProjectPreservesColor() async throws {
183
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
183
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
184
184
  defer { try? FileManager.default.removeItem(at: root) }
185
185
  let bin = root.appendingPathComponent(".bun/bin")
186
186
  try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
@@ -220,7 +220,7 @@ struct ProjectStoreTests {
220
220
  @Test("project mutations are rejected while canonical reconciliation is in flight")
221
221
  @MainActor
222
222
  func serializesReconciliationAndMutations() async throws {
223
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
223
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
224
224
  defer { try? FileManager.default.removeItem(at: root) }
225
225
  let bin = root.appendingPathComponent(".bun/bin")
226
226
  try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
@@ -257,7 +257,7 @@ struct ProjectStoreTests {
257
257
  @Test("project decode failures are visible and prevent readiness")
258
258
  @MainActor
259
259
  func reportsLoadFailure() async throws {
260
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
260
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
261
261
  defer { try? FileManager.default.removeItem(at: root) }
262
262
  try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
263
263
  let file = root.appendingPathComponent("projects.json")
@@ -280,7 +280,7 @@ struct ProjectStoreTests {
280
280
  @Test("unreadable project data blocks every mutation without overwriting the file")
281
281
  @MainActor
282
282
  func unreadableDataBlocksMutations() async throws {
283
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
283
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
284
284
  defer { try? FileManager.default.removeItem(at: root) }
285
285
  try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
286
286
  let file = root.appendingPathComponent("projects.json")
@@ -310,7 +310,7 @@ struct ProjectStoreTests {
310
310
  @Test("a project file that becomes unreadable after launch is never overwritten")
311
311
  @MainActor
312
312
  func postLaunchReadFailureBlocksMutations() throws {
313
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
313
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
314
314
  defer { try? FileManager.default.removeItem(at: root) }
315
315
  try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
316
316
  let file = root.appendingPathComponent("projects.json")
@@ -334,7 +334,7 @@ struct ProjectStoreTests {
334
334
  @Test("external project file changes are never replaced by stale in-memory settings")
335
335
  @MainActor
336
336
  func externalChangeBlocksMutations() throws {
337
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
337
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
338
338
  defer { try? FileManager.default.removeItem(at: root) }
339
339
  try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
340
340
  let file = root.appendingPathComponent("projects.json")
@@ -354,7 +354,7 @@ struct ProjectStoreTests {
354
354
  @Test("a stale app instance cannot overwrite a newer project save")
355
355
  @MainActor
356
356
  func staleStoreCannotOverwriteNewerSave() throws {
357
- let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
357
+ let root = URL(fileURLWithPath: makeIsolatedTestHome("project-store"), isDirectory: true)
358
358
  defer { try? FileManager.default.removeItem(at: root) }
359
359
  try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
360
360
  let file = root.appendingPathComponent("projects.json")
@@ -67,7 +67,7 @@ private func makeEngine(
67
67
  accessibilityTrusted: Bool = true,
68
68
  pasteRecorder: PasteRecorder? = nil
69
69
  ) -> RecordingEngine {
70
- let engine = RecordingEngine(homePath: makeIsolatedTestHome("delivery-tests"))
70
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("delivery-tests"), installsGlobalHandlers: false)
71
71
  engine.openAIAPIKeyProvider = { "" }
72
72
  engine.microphoneAuthorization = { .denied }
73
73
  engine.accessibilityTrustCheck = { accessibilityTrusted }
@@ -0,0 +1,50 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Testing
4
+ @testable import RecordingsLib
5
+
6
+ private struct TargetNoopProvider: RecordingTranscriptionProvider {
7
+ func makeSession(configuration: RecordingProviderSessionConfiguration, onPartialTranscript: @escaping @Sendable (String) -> Void) throws -> any RecordingTranscriptionSession {
8
+ throw RecordingProviderError.noAudio
9
+ }
10
+ }
11
+ @MainActor struct RecordingFrozenPasteTargetTests {
12
+ private let a = PasteApplicationObservation(pid: 90101, bundleIdentifier: "example.editor", launchDate: Date(timeIntervalSince1970: 100), name: "Editor")
13
+ private let b = PasteApplicationObservation(pid: 90102, bundleIdentifier: "example.notes", launchDate: Date(timeIntervalSince1970: 200), name: "Notes")
14
+ private func engine() throws -> RecordingEngine {
15
+ let configuration = try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("frozen-target"), preferencesSuiteName: "example.frozen-tests.\(UUID().uuidString)")
16
+ let engine = RecordingEngine(configuration: configuration, transcriptionProvider: TargetNoopProvider())
17
+ engine.microphoneAuthorization = { .denied }
18
+ engine.accessibilityTrustCheck = { false }
19
+ engine.protectedOperationTrust = { AccessibilityTrustResult(trusted: true, didPrompt: false) }
20
+ engine.focusedWindowTitleLookup = { _ in nil }
21
+ engine.selectionCapture = { _ in Issue.record("No Accessibility reads allowed"); return nil }
22
+ engine.frontmostAppSnapshot = { FrontmostAppSnapshot(pid: ProcessInfo.processInfo.processIdentifier, bundleIdentifier: "example.recorder", launchDate: Date()) }
23
+ engine.pasteTargetApplicationLookup = { pid in pid == a.pid ? a : b }
24
+ return engine
25
+ }
26
+ private func candidates(_ apps: [PasteApplicationObservation]) -> [PasteTargetCandidate] {
27
+ apps.map { PasteTargetCandidate(pid: $0.pid, bundleIdentifier: $0.bundleIdentifier, isRegularApp: $0.isRegular, launchDate: $0.launchDate) }
28
+ }
29
+ @Test func explicitObservedTargetSurvivesSelfFocusAndLaterAppFocus() throws {
30
+ let engine = try engine()
31
+ let target = try #require(RecordingPasteTarget(observation: a, currentPID: ProcessInfo.processInfo.processIdentifier))
32
+ engine.startRecording(pasteTarget: .frozen(target))
33
+ let generation = engine.recordingGeneration
34
+ #expect(engine.resolvePasteTarget(candidates: candidates([a, b]), targetBundleIdentifier: a.bundleIdentifier, targetPid: a.pid, frontmostPid: b.pid, pipelineGeneration: generation)?.pid == a.pid)
35
+ var reused = a; reused.launchDate = Date(timeIntervalSince1970: 101)
36
+ #expect(engine.resolvePasteTarget(candidates: candidates([reused, b]), targetBundleIdentifier: a.bundleIdentifier, targetPid: a.pid, frontmostPid: b.pid, pipelineGeneration: generation) == nil)
37
+ #expect(engine.resolvePasteTarget(candidates: candidates([b]), targetBundleIdentifier: a.bundleIdentifier, targetPid: a.pid, frontmostPid: b.pid, pipelineGeneration: generation) == nil)
38
+ var helper = a; helper.isRegular = false
39
+ #expect(engine.resolvePasteTarget(candidates: candidates([helper]), targetBundleIdentifier: a.bundleIdentifier, targetPid: a.pid, frontmostPid: a.pid, pipelineGeneration: generation) == nil)
40
+ }
41
+ @Test func explicitNilAndInvalidAtStartNeverAdoptTheFinishTimeApp() throws {
42
+ for target in [nil, RecordingPasteTarget(observation: a, currentPID: ProcessInfo.processInfo.processIdentifier)] {
43
+ let engine = try engine(); engine.pasteTargetApplicationLookup = { _ in nil }
44
+ engine.startRecording(pasteTarget: .frozen(target))
45
+ #expect(engine.resolvePasteTarget(candidates: candidates([b]), targetBundleIdentifier: nil, targetPid: nil, frontmostPid: b.pid, pipelineGeneration: engine.recordingGeneration) == nil)
46
+ }
47
+ let legacy = try engine(); legacy.startRecording()
48
+ #expect(legacy.resolvePasteTarget(candidates: candidates([b]), targetBundleIdentifier: nil, targetPid: nil, frontmostPid: b.pid, pipelineGeneration: legacy.recordingGeneration)?.pid == b.pid, "Omitting explicit selection preserves the legacy default")
49
+ }
50
+ }
@@ -0,0 +1,48 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ @MainActor struct RecordingPasteTargetTrackerTests {
6
+ private let a = PasteApplicationObservation(pid: 801, bundleIdentifier: "example.editor", launchDate: Date(timeIntervalSince1970: 100), name: "Editor")
7
+ private let b = PasteApplicationObservation(pid: 802, bundleIdentifier: "example.notes", launchDate: Date(timeIntervalSince1970: 200), name: "Notes")
8
+ private let own = PasteApplicationObservation(pid: 900, bundleIdentifier: "example.recorder", launchDate: Date(timeIntervalSince1970: 300))
9
+
10
+ @Test func remembersObservedExternalAcrossRecorderActivationAndFreezesValue() throws {
11
+ var frontmost = a
12
+ let tracker = RecordingPasteTargetTracker(currentPID: own.pid, frontmost: { frontmost }, lookup: { pid in pid == a.pid ? a : b })
13
+ frontmost = own; tracker.observe(own)
14
+ let frozen = try #require(tracker.snapshot())
15
+ #expect(frozen.processIdentifier == a.pid)
16
+ #expect(frozen.bundleIdentifier == a.bundleIdentifier)
17
+ #expect(frozen.launchDate == a.launchDate)
18
+ frontmost = b; tracker.observe(b)
19
+ #expect(tracker.snapshot()?.processIdentifier == b.pid)
20
+ #expect(frozen.processIdentifier == a.pid, "Later focus cannot mutate a capture's value")
21
+ }
22
+ @Test func refusesUnobservedSelfNonregularIncompleteAndTerminatedApps() {
23
+ let tracker = RecordingPasteTargetTracker(currentPID: own.pid, frontmost: { own }, lookup: { _ in a })
24
+ #expect(tracker.snapshot() == nil)
25
+ for invalid in [PasteApplicationObservation(pid: 803, bundleIdentifier: "example.helper", launchDate: Date(), isRegular: false),
26
+ PasteApplicationObservation(pid: 804, bundleIdentifier: nil, launchDate: Date()),
27
+ PasteApplicationObservation(pid: 805, bundleIdentifier: "example.unknown", launchDate: nil),
28
+ PasteApplicationObservation(pid: 806, bundleIdentifier: "example.dead", launchDate: Date(), isTerminated: true)] {
29
+ tracker.observe(invalid); #expect(tracker.snapshot() == nil)
30
+ }
31
+ }
32
+ @Test func refusesPIDReuseAndDoesNotRecoverAnOlderApp() throws {
33
+ var live = b
34
+ let tracker = RecordingPasteTargetTracker(currentPID: own.pid, frontmost: { own }, lookup: { _ in live })
35
+ tracker.observe(a); tracker.observe(b)
36
+ #expect(tracker.snapshot()?.processIdentifier == b.pid)
37
+ live.launchDate = Date(timeIntervalSince1970: 201)
38
+ #expect(tracker.snapshot() == nil)
39
+ live = b
40
+ #expect(tracker.snapshot() == nil, "Revalidation failure consumes the remembered destination")
41
+ tracker.observe(b)
42
+ var oldIncarnation = b; oldIncarnation.launchDate = Date(timeIntervalSince1970: 199)
43
+ tracker.terminated(oldIncarnation)
44
+ #expect(tracker.snapshot() != nil)
45
+ tracker.terminated(b)
46
+ #expect(tracker.snapshot() == nil)
47
+ }
48
+ }
@@ -6,27 +6,43 @@ import Testing
6
6
  private final class ProviderTestRecorder: PCMRecordingSource, @unchecked Sendable {
7
7
  private let lock = NSLock()
8
8
  private var callback: (@Sendable (Data) -> Void)?
9
+ private let stopPCM: Data
9
10
  private(set) var stopped = false
11
+ init(stopPCM: Data = Data()) { self.stopPCM = stopPCM }
10
12
  func attach(_ callback: @escaping @Sendable (Data) -> Void) { lock.withLock { self.callback = callback } }
11
13
  func emit(_ data: Data) { lock.withLock { callback }?(data) }
12
14
  func start() throws {}
13
- func stop() { lock.withLock { stopped = true } }
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
+ }
14
23
  }
15
24
 
16
25
  private final class ProviderTestSession: RecordingTranscriptionSession, @unchecked Sendable {
17
26
  private let lock = NSLock()
18
27
  private var audio = Data()
28
+ private var packets: [Data] = []
19
29
  private var request: RecordingTranscriptionRequest?
20
30
  private var partial: (@Sendable (String) -> Void)?
21
31
  private var cancelled = false
32
+ private var inputEndings: [Data] = []
22
33
  private var gate: CheckedContinuation<RecordingProviderResult, Never>?
23
34
  let delayFinish: Bool
24
35
 
25
36
  init(delayFinish: Bool = false) { self.delayFinish = delayFinish }
26
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 } }
27
40
  func configure(_ partial: @escaping @Sendable (String) -> Void) { lock.withLock { self.partial = partial } }
28
41
  func emitPartial(_ text: String) { lock.withLock { partial }?(text) }
29
- func appendPCM(_ data: Data) { lock.withLock { if !cancelled { audio.append(data) } } }
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) } } }
30
46
  func finish(_ request: RecordingTranscriptionRequest) async throws -> RecordingProviderResult {
31
47
  if delayFinish {
32
48
  return await withCheckedContinuation { continuation in
@@ -99,6 +115,7 @@ struct RecordingProviderTests {
99
115
  #expect(await eventually { !engine.isTranscribing })
100
116
  let request = try #require(session.snapshot.1)
101
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")
102
119
  #expect(request.duration == Double(6_400) / 48_000)
103
120
  let wav = try Data(contentsOf: request.audioURL)
104
121
  #expect(String(data: wav.prefix(4), encoding: .utf8) == "RIFF")
@@ -113,6 +130,102 @@ struct RecordingProviderTests {
113
130
  #expect(recorder.stopped)
114
131
  }
115
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
+
116
229
  @Test("cancel during provider finalization drops late text and allows another capture")
117
230
  func cancellationDropsStaleCompletions() async throws {
118
231
  let session = ProviderTestSession(delayFinish: true)