@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/Package.swift +6 -2
  2. package/README.md +97 -9
  3. package/android/build.gradle.kts +5 -5
  4. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +119 -0
  5. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +106 -1
  6. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
  7. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
  8. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
  9. package/dist/definitions.d.ts +54 -1
  10. package/dist/definitions.d.ts.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +36 -0
  14. package/dist/index.d.ts +14 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +30 -13
  17. package/dist/streaming.d.ts +17 -0
  18. package/dist/streaming.d.ts.map +1 -0
  19. package/dist/streaming.js +303 -0
  20. package/dist/web.d.ts +17 -2
  21. package/dist/web.d.ts.map +1 -1
  22. package/dist/web.js +70 -0
  23. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +121 -0
  24. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +70 -1
  25. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
  26. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
  27. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
  28. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
  29. package/package.json +18 -6
@@ -0,0 +1,121 @@
1
+ import Foundation
2
+ import IndeRunCore
3
+
4
+ /// Tracks the streaming runs the bridge is currently pumping, keyed by the
5
+ /// bridge-local `streamId`.
6
+ ///
7
+ /// Two things make this more than a dictionary. A cancel can arrive before the run
8
+ /// it refers to exists — `startStream` has to reach route selection before there is
9
+ /// anything to cancel — so a cancel requested in that window is recorded and applied
10
+ /// on attach. And cancelling a run is *not* cancelling the pump task: the engine
11
+ /// answers a cancel with its one `cancelled` terminal event, which the pump still has
12
+ /// to deliver. Task cancellation is reserved for teardown, where the webview is going
13
+ /// away and nobody is left to receive a terminal.
14
+ ///
15
+ /// Deliberately free of any Capacitor dependency, so it is exercised by `swift test`
16
+ /// on macOS where `canImport(Capacitor)` is false.
17
+ final class StreamRegistry {
18
+ enum AttachOutcome: Equatable {
19
+ case attached
20
+ /// A cancel arrived before the run did; the caller must apply it.
21
+ case cancelRequested(reason: String?)
22
+ }
23
+
24
+ private struct Entry {
25
+ var run: StreamRun?
26
+ var task: Task<Void, Never>?
27
+ var cancelRequested = false
28
+ var cancelReason: String?
29
+ }
30
+
31
+ private let lock = NSLock()
32
+ private var entries: [String: Entry] = [:]
33
+
34
+ var activeCount: Int {
35
+ lock.lock()
36
+ defer { lock.unlock() }
37
+ return entries.count
38
+ }
39
+
40
+ /// Reserves the id so a cancel arriving before the run can be recorded rather
41
+ /// than dropped as unknown.
42
+ func open(streamId: String) {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ if entries[streamId] == nil {
46
+ entries[streamId] = Entry()
47
+ }
48
+ }
49
+
50
+ func attach(streamId: String, run: StreamRun) -> AttachOutcome {
51
+ lock.lock()
52
+ guard var entry = entries[streamId] else {
53
+ // Torn down while the engine was still selecting a route.
54
+ lock.unlock()
55
+ return .cancelRequested(reason: nil)
56
+ }
57
+ if entry.cancelRequested {
58
+ let reason = entry.cancelReason
59
+ lock.unlock()
60
+ return .cancelRequested(reason: reason)
61
+ }
62
+ entry.run = run
63
+ entries[streamId] = entry
64
+ lock.unlock()
65
+ return .attached
66
+ }
67
+
68
+ func store(task: Task<Void, Never>, for streamId: String) {
69
+ lock.lock()
70
+ defer { lock.unlock() }
71
+ guard var entry = entries[streamId] else {
72
+ task.cancel()
73
+ return
74
+ }
75
+ entry.task = task
76
+ entries[streamId] = entry
77
+ }
78
+
79
+ /// Cancels the run if it has attached, otherwise records the request for attach.
80
+ /// An unknown id — already finished, or never started — is a silent no-op, which
81
+ /// is what makes cancelling after the terminal harmless.
82
+ func requestCancel(streamId: String, reason: String?) {
83
+ lock.lock()
84
+ guard var entry = entries[streamId] else {
85
+ lock.unlock()
86
+ return
87
+ }
88
+ if entry.cancelRequested {
89
+ lock.unlock()
90
+ return
91
+ }
92
+ entry.cancelRequested = true
93
+ entry.cancelReason = reason
94
+ let run = entry.run
95
+ entries[streamId] = entry
96
+ lock.unlock()
97
+
98
+ // Never under the lock: cancel reaches into engine code.
99
+ run?.cancel(reason: reason)
100
+ }
101
+
102
+ func finish(streamId: String) {
103
+ lock.lock()
104
+ defer { lock.unlock() }
105
+ entries.removeValue(forKey: streamId)
106
+ }
107
+
108
+ /// Teardown. Cancels each run first so providers unwind, then the pump tasks,
109
+ /// which at this point have nobody left to deliver to.
110
+ func cancelAll(reason: String?) {
111
+ lock.lock()
112
+ let draining = entries
113
+ entries.removeAll()
114
+ lock.unlock()
115
+
116
+ for (_, entry) in draining {
117
+ entry.run?.cancel(reason: reason)
118
+ entry.task?.cancel()
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,178 @@
1
+ import XCTest
2
+ import Foundation
3
+ import IndeRunContracts
4
+ import IndeRunCore
5
+ @testable import IndeRunCapacitorPlugin
6
+
7
+ /// Encoding is what the JS side actually consumes, so these assert the exact key
8
+ /// sets rather than round-tripping: an optional that encodes as `null` instead of
9
+ /// being absent changes which union branch the consumer reconstitutes.
10
+ final class IndeRunCapacitorStreamCodecTests: XCTestCase {
11
+
12
+ private func event(
13
+ type: String,
14
+ sequence: Int = 0,
15
+ payload: Payload?,
16
+ runId: String = "run_1"
17
+ ) -> StreamEvent {
18
+ StreamEvent(
19
+ payload: payload,
20
+ runId: runId,
21
+ schemaVersion: .the10,
22
+ sequence: sequence,
23
+ timestamp: 1_700_000_000_000,
24
+ type: type
25
+ )
26
+ }
27
+
28
+ func testEncodesContentDeltaWithOnlyTheTextField() throws {
29
+ let bridge = IndeRunCapacitorBridge()
30
+ let encoded = try bridge.encode(
31
+ streamEvent: event(type: "content_delta", payload: .content(text: "hello"))
32
+ )
33
+
34
+ XCTAssertEqual(encoded["schemaVersion"] as? String, "1.0")
35
+ XCTAssertEqual(encoded["runId"] as? String, "run_1")
36
+ XCTAssertEqual(encoded["sequence"] as? Int, 0)
37
+ XCTAssertEqual(encoded["type"] as? String, "content_delta")
38
+
39
+ let payload = try XCTUnwrap(encoded["payload"] as? [String: Any])
40
+ XCTAssertEqual(payload["text"] as? String, "hello")
41
+ XCTAssertEqual(Set(payload.keys), ["text"])
42
+ }
43
+
44
+ func testEncodesTerminalCompletedOutcome() throws {
45
+ let bridge = IndeRunCapacitorBridge()
46
+ let outcome = StreamTerminalOutcome.completed(
47
+ runId: "run_1",
48
+ finalText: "all done",
49
+ finishReason: .stop,
50
+ usage: TaskResultUsage(inputTokens: 3, outputTokens: 5, totalTokens: 8),
51
+ telemetry: StreamTerminalOutcomeTelemetry(providerUsed: "openai", totalMs: 42)
52
+ )
53
+
54
+ let encoded = try bridge.encode(
55
+ streamEvent: event(type: "terminal", sequence: 4, payload: Payload(terminalOutcome: outcome))
56
+ )
57
+ let payload = try XCTUnwrap(encoded["payload"] as? [String: Any])
58
+
59
+ XCTAssertEqual(payload["outcome"] as? String, "completed")
60
+ XCTAssertEqual(payload["finalText"] as? String, "all done")
61
+ XCTAssertEqual(payload["finishReason"] as? String, "stop")
62
+ XCTAssertEqual((payload["usage"] as? [String: Any])?["totalTokens"] as? Int, 8)
63
+ XCTAssertEqual((payload["telemetry"] as? [String: Any])?["providerUsed"] as? String, "openai")
64
+ // The error/cancellation branches' fields must not leak into a completion.
65
+ XCTAssertNil(payload["error"])
66
+ XCTAssertNil(payload["partialText"])
67
+ XCTAssertNil(payload["reason"])
68
+ }
69
+
70
+ /// Terminal errors nest deeper than anything `run()` produces, which is the case
71
+ /// that would break `encodeObject`'s `as? JSObject` cast if a nil ever encoded as
72
+ /// NSNull.
73
+ func testEncodesTerminalErrorOutcomeWithNestedDetails() throws {
74
+ let bridge = IndeRunCapacitorBridge()
75
+ let error = IndeRunError(
76
+ details: ["endpoint": JSONAny("https://example.test")],
77
+ errorClass: .RateLimited,
78
+ message: "Too many requests.",
79
+ providerId: "openai",
80
+ retryable: true,
81
+ retryAfterMs: 2_000,
82
+ runId: "run_1",
83
+ schemaVersion: .the10
84
+ )
85
+ let outcome = StreamTerminalOutcome.failed(runId: "run_1", error: error, partialText: "half ")
86
+
87
+ let encoded = try bridge.encode(
88
+ streamEvent: event(type: "terminal", sequence: 7, payload: Payload(terminalOutcome: outcome))
89
+ )
90
+ let payload = try XCTUnwrap(encoded["payload"] as? [String: Any])
91
+ let encodedError = try XCTUnwrap(payload["error"] as? [String: Any])
92
+
93
+ XCTAssertEqual(payload["outcome"] as? String, "error")
94
+ XCTAssertEqual(payload["partialText"] as? String, "half ")
95
+ XCTAssertEqual(encodedError["errorClass"] as? String, "RateLimited")
96
+ XCTAssertEqual(encodedError["retryAfterMs"] as? Int, 2_000)
97
+ XCTAssertEqual((encodedError["details"] as? [String: Any])?["endpoint"] as? String,
98
+ "https://example.test")
99
+ }
100
+
101
+ func testEncodesTerminalCancelledOutcomeWithAndWithoutReason() throws {
102
+ let bridge = IndeRunCapacitorBridge()
103
+
104
+ let withReason = try bridge.encode(
105
+ streamEvent: event(
106
+ type: "terminal",
107
+ payload: Payload(
108
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "part", reason: "user left")
109
+ )
110
+ )
111
+ )
112
+ XCTAssertEqual((withReason["payload"] as? [String: Any])?["reason"] as? String, "user left")
113
+
114
+ let withoutReason = try bridge.encode(
115
+ streamEvent: event(
116
+ type: "terminal",
117
+ payload: Payload(
118
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "part", reason: nil)
119
+ )
120
+ )
121
+ )
122
+ let payload = try XCTUnwrap(withoutReason["payload"] as? [String: Any])
123
+ XCTAssertEqual(payload["outcome"] as? String, "cancelled")
124
+ XCTAssertEqual(payload["partialText"] as? String, "part")
125
+ // Absent, not null: a null would decode as a present-but-empty reason.
126
+ XCTAssertFalse(payload.keys.contains("reason"))
127
+ }
128
+
129
+ func testEncodesLifecyclePhase() throws {
130
+ let bridge = IndeRunCapacitorBridge()
131
+ let payload = Payload(
132
+ text: nil, phase: .providerSelected, finalText: nil, finishReason: nil, outcome: nil,
133
+ runId: nil, schemaVersion: nil, telemetry: nil, usage: nil, error: nil,
134
+ partialText: nil, reason: nil
135
+ )
136
+
137
+ let encoded = try bridge.encode(streamEvent: event(type: "lifecycle", payload: payload))
138
+ XCTAssertEqual((encoded["payload"] as? [String: Any])?["phase"] as? String, "provider_selected")
139
+ }
140
+
141
+ /// The schema closes its union with an open catch-all branch; an unrecognized
142
+ /// type must cross the bridge untouched rather than be rejected.
143
+ func testEncodesAnUnknownEventTypeVerbatimWithoutPayload() throws {
144
+ let bridge = IndeRunCapacitorBridge()
145
+ let encoded = try bridge.encode(
146
+ streamEvent: event(type: "some_future_type", sequence: 2, payload: nil)
147
+ )
148
+
149
+ XCTAssertEqual(encoded["type"] as? String, "some_future_type")
150
+ XCTAssertFalse(encoded.keys.contains("payload"))
151
+ }
152
+
153
+ func testEncodesStreamRunHandleWithAndWithoutProviderId() throws {
154
+ let bridge = IndeRunCapacitorBridge()
155
+
156
+ let withProvider = try bridge.encode(
157
+ handle: StreamRunHandle(
158
+ providerId: "openai",
159
+ runId: "run_1",
160
+ schemaVersion: .the10,
161
+ startedAt: 1_700_000_000_000
162
+ )
163
+ )
164
+ XCTAssertEqual(withProvider["providerId"] as? String, "openai")
165
+ XCTAssertEqual(withProvider["runId"] as? String, "run_1")
166
+ XCTAssertEqual(withProvider["startedAt"] as? Double, 1_700_000_000_000)
167
+
168
+ let withoutProvider = try bridge.encode(
169
+ handle: StreamRunHandle(
170
+ providerId: nil,
171
+ runId: "run_1",
172
+ schemaVersion: .the10,
173
+ startedAt: 1_700_000_000_000
174
+ )
175
+ )
176
+ XCTAssertFalse(withoutProvider.keys.contains("providerId"))
177
+ }
178
+ }
@@ -0,0 +1,215 @@
1
+ import XCTest
2
+ import Foundation
3
+ import IndeRunContracts
4
+ import IndeRunCore
5
+ @testable import IndeRunCapacitorPlugin
6
+
7
+ /// Collects what the pump hands to its sinks.
8
+ private final class SinkRecorder: @unchecked Sendable {
9
+ private let lock = NSLock()
10
+ private var events: [JSObject] = []
11
+ private var errors: [JSObject] = []
12
+
13
+ func recordEvent(_ event: JSObject) {
14
+ lock.lock()
15
+ defer { lock.unlock() }
16
+ events.append(event)
17
+ }
18
+
19
+ func recordError(_ error: JSObject) {
20
+ lock.lock()
21
+ defer { lock.unlock() }
22
+ errors.append(error)
23
+ }
24
+
25
+ var eventTypes: [String] {
26
+ lock.lock()
27
+ defer { lock.unlock() }
28
+ return events.compactMap { $0["type"] as? String }
29
+ }
30
+
31
+ var eventSequences: [Int] {
32
+ lock.lock()
33
+ defer { lock.unlock() }
34
+ return events.compactMap { $0["sequence"] as? Int }
35
+ }
36
+
37
+ var errorClasses: [String] {
38
+ lock.lock()
39
+ defer { lock.unlock() }
40
+ return errors.compactMap { $0["errorClass"] as? String }
41
+ }
42
+ }
43
+
44
+ final class IndeRunCapacitorStreamPumpTests: XCTestCase {
45
+
46
+ private func event(_ sequence: Int, type: String = "content_delta") -> StreamEvent {
47
+ StreamEvent(
48
+ payload: .content(text: "chunk\(sequence)"),
49
+ runId: "run_1",
50
+ schemaVersion: .the10,
51
+ sequence: sequence,
52
+ timestamp: 1_700_000_000_000,
53
+ type: type
54
+ )
55
+ }
56
+
57
+ private func cancelledTerminal(_ sequence: Int) -> StreamEvent {
58
+ StreamEvent(
59
+ payload: Payload(
60
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "part", reason: "stopped")
61
+ ),
62
+ runId: "run_1",
63
+ schemaVersion: .the10,
64
+ sequence: sequence,
65
+ timestamp: 1_700_000_000_000,
66
+ type: "terminal"
67
+ )
68
+ }
69
+
70
+ private func makeRun(
71
+ continuation: inout AsyncThrowingStream<StreamEvent, Error>.Continuation?,
72
+ cancel: @escaping @Sendable (String?) -> Void = { _ in }
73
+ ) -> StreamRun {
74
+ let (events, made) = AsyncThrowingStream<StreamEvent, Error>.makeStream()
75
+ continuation = made
76
+ return StreamRun(
77
+ handle: StreamRunHandle(
78
+ providerId: nil,
79
+ runId: "run_1",
80
+ schemaVersion: .the10,
81
+ startedAt: 1_700_000_000_000
82
+ ),
83
+ events: events,
84
+ cancel: cancel
85
+ )
86
+ }
87
+
88
+ private func pump(_ bridge: IndeRunCapacitorBridge, _ run: StreamRun, _ recorder: SinkRecorder) -> Task<Void, Never> {
89
+ Task {
90
+ await bridge.pump(
91
+ streamId: "s1",
92
+ run: run,
93
+ onEvent: { _, event in recorder.recordEvent(event) },
94
+ onError: { _, error in recorder.recordError(error) }
95
+ )
96
+ }
97
+ }
98
+
99
+ func testForwardsEveryEventInOrderAndFinishesTheRegistryEntry() async {
100
+ let bridge = IndeRunCapacitorBridge()
101
+ let recorder = SinkRecorder()
102
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
103
+ let run = makeRun(continuation: &continuation)
104
+
105
+ bridge.streams.open(streamId: "s1")
106
+ bridge.streams.attach(streamId: "s1", run: run)
107
+ let task = pump(bridge, run, recorder)
108
+
109
+ continuation?.yield(event(0))
110
+ continuation?.yield(event(1))
111
+ continuation?.finish()
112
+ await task.value
113
+
114
+ XCTAssertEqual(recorder.eventSequences, [0, 1])
115
+ XCTAssertTrue(recorder.errorClasses.isEmpty)
116
+ XCTAssertEqual(bridge.streams.activeCount, 0)
117
+ }
118
+
119
+ /// A provider failure is already a terminal event by the time it reaches the
120
+ /// pump; anything actually thrown out of the sequence is a bridge fault.
121
+ func testMapsAThrownStreamErrorToASingleNormalizedErrorSink() async {
122
+ let bridge = IndeRunCapacitorBridge()
123
+ let recorder = SinkRecorder()
124
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
125
+ let run = makeRun(continuation: &continuation)
126
+
127
+ bridge.streams.open(streamId: "s1")
128
+ let task = pump(bridge, run, recorder)
129
+
130
+ continuation?.yield(event(0))
131
+ continuation?.finish(
132
+ throwing: IndeRunException(errorClass: .Internal, message: "pump blew up")
133
+ )
134
+ await task.value
135
+
136
+ XCTAssertEqual(recorder.eventSequences, [0])
137
+ XCTAssertEqual(recorder.errorClasses, ["Internal"])
138
+ XCTAssertEqual(bridge.streams.activeCount, 0)
139
+ }
140
+
141
+ /// Cancelling a run must not stop the pump: the engine answers a cancel with its
142
+ /// one `cancelled` terminal, and that still has to be delivered.
143
+ func testCancelDuringEmitStillForwardsTheCancelledTerminal() async {
144
+ let bridge = IndeRunCapacitorBridge()
145
+ let recorder = SinkRecorder()
146
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
147
+
148
+ // Stand in for the engine: a cancel produces the cancelled terminal and ends
149
+ // the sequence.
150
+ nonisolated(unsafe) var capture: AsyncThrowingStream<StreamEvent, Error>.Continuation?
151
+ let run = makeRun(continuation: &continuation, cancel: { _ in
152
+ capture?.yield(
153
+ StreamEvent(
154
+ payload: Payload(
155
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "chunk0", reason: "stopped")
156
+ ),
157
+ runId: "run_1",
158
+ schemaVersion: .the10,
159
+ sequence: 1,
160
+ timestamp: 1_700_000_000_000,
161
+ type: "terminal"
162
+ )
163
+ )
164
+ capture?.finish()
165
+ })
166
+ capture = continuation
167
+
168
+ bridge.streams.open(streamId: "s1")
169
+ bridge.streams.attach(streamId: "s1", run: run)
170
+ let task = pump(bridge, run, recorder)
171
+
172
+ continuation?.yield(event(0))
173
+ bridge.streams.requestCancel(streamId: "s1", reason: "stopped")
174
+ await task.value
175
+
176
+ XCTAssertEqual(recorder.eventTypes, ["content_delta", "terminal"])
177
+ XCTAssertTrue(recorder.errorClasses.isEmpty)
178
+ }
179
+
180
+ /// Teardown cancels the pump task itself. That is our own doing, with no one left
181
+ /// to receive anything, so it must not be reported as a bridge failure.
182
+ func testOwnTaskCancellationProducesNoErrorSink() async {
183
+ let bridge = IndeRunCapacitorBridge()
184
+ let recorder = SinkRecorder()
185
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
186
+ let run = makeRun(continuation: &continuation)
187
+
188
+ bridge.streams.open(streamId: "s1")
189
+ let task = pump(bridge, run, recorder)
190
+
191
+ continuation?.yield(event(0))
192
+ continuation?.finish(throwing: CancellationError())
193
+ await task.value
194
+
195
+ XCTAssertEqual(recorder.eventSequences, [0])
196
+ XCTAssertTrue(recorder.errorClasses.isEmpty)
197
+ }
198
+
199
+ func testForwardsAnUnknownEventTypeWithoutTreatingItAsTerminal() async {
200
+ let bridge = IndeRunCapacitorBridge()
201
+ let recorder = SinkRecorder()
202
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
203
+ let run = makeRun(continuation: &continuation)
204
+
205
+ bridge.streams.open(streamId: "s1")
206
+ let task = pump(bridge, run, recorder)
207
+
208
+ continuation?.yield(event(0, type: "some_future_type"))
209
+ continuation?.yield(cancelledTerminal(1))
210
+ continuation?.finish()
211
+ await task.value
212
+
213
+ XCTAssertEqual(recorder.eventTypes, ["some_future_type", "terminal"])
214
+ }
215
+ }
@@ -0,0 +1,154 @@
1
+ import XCTest
2
+ import Foundation
3
+ import IndeRunContracts
4
+ import IndeRunCore
5
+ @testable import IndeRunCapacitorPlugin
6
+
7
+ /// Records the cancel calls a fake `StreamRun` receives, so a test can assert both
8
+ /// that a cancel arrived and what reason it carried.
9
+ private final class CancelRecorder: @unchecked Sendable {
10
+ private let lock = NSLock()
11
+ private var calls: [String?] = []
12
+
13
+ func record(_ reason: String?) {
14
+ lock.lock()
15
+ defer { lock.unlock() }
16
+ calls.append(reason)
17
+ }
18
+
19
+ var count: Int {
20
+ lock.lock()
21
+ defer { lock.unlock() }
22
+ return calls.count
23
+ }
24
+
25
+ var reasons: [String?] {
26
+ lock.lock()
27
+ defer { lock.unlock() }
28
+ return calls
29
+ }
30
+ }
31
+
32
+ final class IndeRunCapacitorStreamRegistryTests: XCTestCase {
33
+
34
+ private func makeRun(recorder: CancelRecorder) -> StreamRun {
35
+ let (events, continuation) = AsyncThrowingStream<StreamEvent, Error>.makeStream()
36
+ continuation.finish()
37
+ return StreamRun(
38
+ handle: StreamRunHandle(
39
+ providerId: nil,
40
+ runId: "run_1",
41
+ schemaVersion: .the10,
42
+ startedAt: 1_700_000_000_000
43
+ ),
44
+ events: events,
45
+ cancel: { reason in recorder.record(reason) }
46
+ )
47
+ }
48
+
49
+ func testAttachesAndFinishesByStreamId() {
50
+ let registry = StreamRegistry()
51
+ let recorder = CancelRecorder()
52
+
53
+ registry.open(streamId: "s1")
54
+ XCTAssertEqual(registry.attach(streamId: "s1", run: makeRun(recorder: recorder)), .attached)
55
+ XCTAssertEqual(registry.activeCount, 1)
56
+
57
+ registry.finish(streamId: "s1")
58
+ XCTAssertEqual(registry.activeCount, 0)
59
+ }
60
+
61
+ /// A cancel can beat the engine's route selection. It must be held and applied
62
+ /// when the run finally attaches, not dropped.
63
+ func testCancelBeforeAttachIsAppliedOnAttachWithItsReason() {
64
+ let registry = StreamRegistry()
65
+ let recorder = CancelRecorder()
66
+
67
+ registry.open(streamId: "s1")
68
+ registry.requestCancel(streamId: "s1", reason: "user left")
69
+ // Nothing to cancel yet, so nothing was called.
70
+ XCTAssertEqual(recorder.count, 0)
71
+
72
+ let outcome = registry.attach(streamId: "s1", run: makeRun(recorder: recorder))
73
+ XCTAssertEqual(outcome, .cancelRequested(reason: "user left"))
74
+ }
75
+
76
+ func testCancelAfterFinishIsANoOp() {
77
+ let registry = StreamRegistry()
78
+ let recorder = CancelRecorder()
79
+
80
+ registry.open(streamId: "s1")
81
+ registry.attach(streamId: "s1", run: makeRun(recorder: recorder))
82
+ registry.finish(streamId: "s1")
83
+
84
+ registry.requestCancel(streamId: "s1", reason: "too late")
85
+ XCTAssertEqual(recorder.count, 0)
86
+ }
87
+
88
+ func testCancelForAnUnknownStreamIdIsANoOp() {
89
+ let registry = StreamRegistry()
90
+ registry.requestCancel(streamId: "never_opened", reason: nil)
91
+ XCTAssertEqual(registry.activeCount, 0)
92
+ }
93
+
94
+ func testRepeatedCancelCancelsTheRunExactlyOnce() {
95
+ let registry = StreamRegistry()
96
+ let recorder = CancelRecorder()
97
+
98
+ registry.open(streamId: "s1")
99
+ registry.attach(streamId: "s1", run: makeRun(recorder: recorder))
100
+
101
+ registry.requestCancel(streamId: "s1", reason: "first")
102
+ registry.requestCancel(streamId: "s1", reason: "second")
103
+
104
+ XCTAssertEqual(recorder.reasons, ["first"])
105
+ }
106
+
107
+ func testConcurrentStreamsAreIsolated() {
108
+ let registry = StreamRegistry()
109
+ let first = CancelRecorder()
110
+ let second = CancelRecorder()
111
+
112
+ registry.open(streamId: "s1")
113
+ registry.open(streamId: "s2")
114
+ registry.attach(streamId: "s1", run: makeRun(recorder: first))
115
+ registry.attach(streamId: "s2", run: makeRun(recorder: second))
116
+
117
+ registry.requestCancel(streamId: "s1", reason: "only the first")
118
+
119
+ XCTAssertEqual(first.reasons, ["only the first"])
120
+ XCTAssertEqual(second.count, 0)
121
+ XCTAssertEqual(registry.activeCount, 2)
122
+ }
123
+
124
+ func testCancelAllCancelsEveryRunOnceAndEmptiesTheRegistry() {
125
+ let registry = StreamRegistry()
126
+ let first = CancelRecorder()
127
+ let second = CancelRecorder()
128
+
129
+ registry.open(streamId: "s1")
130
+ registry.open(streamId: "s2")
131
+ registry.attach(streamId: "s1", run: makeRun(recorder: first))
132
+ registry.attach(streamId: "s2", run: makeRun(recorder: second))
133
+
134
+ registry.cancelAll(reason: "torn down")
135
+
136
+ XCTAssertEqual(first.reasons, ["torn down"])
137
+ XCTAssertEqual(second.reasons, ["torn down"])
138
+ XCTAssertEqual(registry.activeCount, 0)
139
+ }
140
+
141
+ func testAttachAfterTeardownReportsCancelRequested() {
142
+ let registry = StreamRegistry()
143
+ let recorder = CancelRecorder()
144
+
145
+ registry.open(streamId: "s1")
146
+ registry.cancelAll(reason: "torn down")
147
+
148
+ // The entry is gone, so the run that arrives late must be cancelled by its caller.
149
+ XCTAssertEqual(
150
+ registry.attach(streamId: "s1", run: makeRun(recorder: recorder)),
151
+ .cancelRequested(reason: nil)
152
+ )
153
+ }
154
+ }