@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.
- package/README.md +56 -6
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +28 -7
- 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/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/index.js +27 -6
- package/dist/mcp/index.js +27 -6
- package/dist/server/index.js +27 -6
- package/dist/storage.js +27 -6
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +27 -6
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/macos_artifact.ts +40 -33
- 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/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- 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 +115 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/build.sh +2 -1
- 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
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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() {
|
|
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) {
|
|
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)
|